Snap for 11670064 from 765b68f8ee17754f66abefd8a9b5ba312bc73e15 to mainline-cellbroadcast-release

Change-Id: I785fda9a3f643dcafdfd7a3d9d322560837875e4
diff --git a/Cronet/tests/common/Android.bp b/Cronet/tests/common/Android.bp
index edeb0b3..703f544 100644
--- a/Cronet/tests/common/Android.bp
+++ b/Cronet/tests/common/Android.bp
@@ -43,6 +43,7 @@
     jni_libs: [
         "cronet_aml_components_cronet_android_cronet_tests__testing",
         "cronet_aml_third_party_netty_tcnative_netty_tcnative_so__testing",
+        "libnativecoverage",
     ],
     data: [":cronet_javatests_resources"],
 }
diff --git a/Cronet/tests/common/AndroidTest.xml b/Cronet/tests/common/AndroidTest.xml
index bded8fb..7646a04 100644
--- a/Cronet/tests/common/AndroidTest.xml
+++ b/Cronet/tests/common/AndroidTest.xml
@@ -35,26 +35,19 @@
         <option name="runner" value="androidx.test.runner.AndroidJUnitRunner" />
         <!-- b/298380508 -->
         <option name="exclude-filter" value="org.chromium.net.CronetUrlRequestContextTest#testSetLibraryLoaderIsEnforcedByDefaultEmbeddedProvider" />
-        <option name="exclude-filter" value="org.chromium.net.CronetUrlRequestContextTest#testSetLibraryLoaderIsIgnoredInNativeCronetEngineBuilderImpl" />
-        <!-- b/316571753 -->
-        <option name="exclude-filter" value="org.chromium.net.CronetUrlRequestContextTest#testBaseFeatureFlagsOverridesEnabled" />
-        <option name="exclude-filter" value="org.chromium.net.CronetUrlRequestContextTest#testHttpFlagsAppliedIfAppIdMatches" />
-        <option name="exclude-filter" value="org.chromium.net.CronetUrlRequestContextTest#testHttpFlagsAreLoaded" />
-        <option name="exclude-filter" value="org.chromium.net.CronetUrlRequestContextTest#testSetLibraryLoaderIsEnforcedByDefaultEmbeddedProvider" />
-        <option name="exclude-filter" value="org.chromium.net.CronetUrlRequestContextTest#testHttpFlagsAppliedIfAtMinVersion" />
-        <option name="exclude-filter" value="org.chromium.net.CronetUrlRequestContextTest#testHttpFlagsAppliedIfAboveMinVersion" />
-        <!-- b/316567693 -->
-        <option name="exclude-filter" value="org.chromium.net.CronetUrlRequestTest#testSSLCertificateError" />
         <!-- b/316559294 -->
         <option name="exclude-filter" value="org.chromium.net.NQETest#testQuicDisabled" />
         <!-- b/316559294 -->
         <option name="exclude-filter" value="org.chromium.net.NQETest#testPrefsWriteRead" />
         <!-- b/316554711-->
-        <option name="exclude-filter" value="org.chromium.net.NetworkChangesTest" /> 
+        <option name="exclude-filter" value="org.chromium.net.NetworkChangesTest" />
         <!-- b/316550794 -->
         <option name="exclude-filter" value="org.chromium.net.impl.CronetLoggerTest#testEngineCreation" />
+        <!-- b/327182569 -->
+        <option name="exclude-filter" value="org.chromium.net.urlconnection.CronetURLStreamHandlerFactoryTest#testSetUrlStreamFactoryUsesCronetForNative" />
         <option name="hidden-api-checks" value="false"/>
         <option name="isolated-storage" value="false"/>
+        <option name="orchestrator" value="true"/>
         <option
             name="device-listeners"
             value="com.android.modules.utils.testing.NativeCoverageHackInstrumentationListener" />
diff --git a/Cronet/tests/mts/AndroidTest.xml b/Cronet/tests/mts/AndroidTest.xml
index bccbe29..a438e2e 100644
--- a/Cronet/tests/mts/AndroidTest.xml
+++ b/Cronet/tests/mts/AndroidTest.xml
@@ -35,16 +35,6 @@
         <option name="runner" value="androidx.test.runner.AndroidJUnitRunner" />
         <!-- b/298380508 -->
         <option name="exclude-filter" value="org.chromium.net.CronetUrlRequestContextTest#testSetLibraryLoaderIsEnforcedByDefaultEmbeddedProvider" />
-        <option name="exclude-filter" value="org.chromium.net.CronetUrlRequestContextTest#testSetLibraryLoaderIsIgnoredInNativeCronetEngineBuilderImpl" />
-        <!-- b/316571753 -->
-        <option name="exclude-filter" value="org.chromium.net.CronetUrlRequestContextTest#testBaseFeatureFlagsOverridesEnabled" />
-        <option name="exclude-filter" value="org.chromium.net.CronetUrlRequestContextTest#testHttpFlagsAppliedIfAppIdMatches" />
-        <option name="exclude-filter" value="org.chromium.net.CronetUrlRequestContextTest#testHttpFlagsAreLoaded" />
-        <option name="exclude-filter" value="org.chromium.net.CronetUrlRequestContextTest#testSetLibraryLoaderIsEnforcedByDefaultEmbeddedProvider" />
-        <option name="exclude-filter" value="org.chromium.net.CronetUrlRequestContextTest#testHttpFlagsAppliedIfAtMinVersion" />
-        <option name="exclude-filter" value="org.chromium.net.CronetUrlRequestContextTest#testHttpFlagsAppliedIfAboveMinVersion" />
-        <!-- b/316567693 -->
-        <option name="exclude-filter" value="org.chromium.net.CronetUrlRequestTest#testSSLCertificateError" />
         <!-- b/316559294 -->
         <option name="exclude-filter" value="org.chromium.net.NQETest#testQuicDisabled" />
         <!-- b/316559294 -->
@@ -53,8 +43,11 @@
         <option name="exclude-filter" value="org.chromium.net.NetworkChangesTest" />
         <!-- b/316550794 -->
         <option name="exclude-filter" value="org.chromium.net.impl.CronetLoggerTest#testEngineCreation" />
+        <!-- b/327182569 -->
+        <option name="exclude-filter" value="org.chromium.net.urlconnection.CronetURLStreamHandlerFactoryTest#testSetUrlStreamFactoryUsesCronetForNative" />
         <option name="hidden-api-checks" value="false"/>
         <option name="isolated-storage" value="false"/>
+        <option name="orchestrator" value="true"/>
     </test>
 
     <!-- Only run NetHttpTests in MTS if the Tethering Mainline module is installed. -->
@@ -62,4 +55,4 @@
             class="com.android.tradefed.testtype.suite.module.MainlineTestModuleController">
         <option name="mainline-module-package-name" value="com.google.android.tethering" />
     </object>
-</configuration>
\ No newline at end of file
+</configuration>
diff --git a/Cronet/tools/import/copy.bara.sky b/Cronet/tools/import/copy.bara.sky
deleted file mode 100644
index 61e3ba4..0000000
--- a/Cronet/tools/import/copy.bara.sky
+++ /dev/null
@@ -1,127 +0,0 @@
-# Copyright 2023 Google Inc. All rights reserved.
-#
-# 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.
-
-common_excludes = [
-    # Exclude all Android build files
-    "**/Android.bp",
-    "**/Android.mk",
-
-    # Exclude existing *OWNERS files
-    "**/*OWNERS",
-    "**/.git/**",
-    "**/.gitignore",
-]
-
-cronet_origin_files = glob(
-    include = [
-        "base/**",
-        "build/**",
-        "build/buildflag.h",
-        "chrome/VERSION",
-        "components/cronet/**",
-        "components/metrics/**",
-        "components/nacl/**",
-        "components/prefs/**",
-        "crypto/**",
-        "ipc/**",
-        "net/**",
-        # Note: Only used for tests.
-        "testing/**",
-        "url/**",
-        "LICENSE",
-    ],
-    exclude = common_excludes + [
-        # Per aosp/2367109
-        "build/android/CheckInstallApk-debug.apk",
-        "build/android/unused_resources/**",
-        "build/linux/**",
-
-        # Per aosp/2374766
-        "components/cronet/ios/**",
-        "components/cronet/native/**",
-
-        # Per aosp/2399270
-        "testing/buildbot/**",
-
-        # Exclude all third-party directories. Those are specified explicitly
-        # below, so no dependency can accidentally creep in.
-        "**/third_party/**",
-    ],
-) + glob(
-    # Explicitly include third-party dependencies.
-    # Note: some third-party dependencies include a third_party folder within
-    # them. So far, this has not become a problem.
-    include = [
-        "base/third_party/cityhash/**",
-        "base/third_party/cityhash_v103/**",
-        "base/third_party/double_conversion/**",
-        "base/third_party/dynamic_annotations/**",
-        "base/third_party/icu/**",
-        "base/third_party/nspr/**",
-        "base/third_party/superfasthash/**",
-        "base/third_party/valgrind/**",
-        # Those are temporarily needed until Chromium finish the migration
-        # of libc++[abi]
-        "buildtools/third_party/libc++/**",
-        "buildtools/third_party/libc++abi/**",
-        # Note: Only used for tests.
-        "net/third_party/nist-pkits/**",
-        "net/third_party/quiche/**",
-        "net/third_party/uri_template/**",
-        "third_party/abseil-cpp/**",
-        "third_party/android_ndk/sources/android/cpufeatures/**",
-        "third_party/ashmem/**",
-        "third_party/boringssl/**",
-        "third_party/brotli/**",
-        # Note: Only used for tests.
-        "third_party/ced/**",
-        "third_party/cpu_features/**",
-        # Note: Only used for tests.
-        "third_party/google_benchmark/**",
-        # Note: Only used for tests.
-        "third_party/googletest/**",
-        "third_party/icu/**",
-        "third_party/jni_zero/**",
-        "third_party/libc++/**",
-        "third_party/libc++abi/**",
-        "third_party/libevent/**",
-        # Note: Only used for tests.
-        "third_party/libxml/**",
-        # Note: Only used for tests.
-        "third_party/lss/**",
-        "third_party/metrics_proto/**",
-        "third_party/modp_b64/**",
-        "third_party/protobuf/**",
-        # Note: Only used for tests.
-        "third_party/quic_trace/**",
-        # Note: Cronet currently uses Android's zlib
-        # "third_party/zlib/**",
-        "url/third_party/mozilla/**",
-    ],
-    exclude = common_excludes,
-)
-
-core.workflow(
-    name = "import_cronet",
-    authoring = authoring.overwrite("Cronet Mainline Eng <cronet-mainline-eng+copybara@google.com>"),
-    # Origin folder is specified via source_ref argument, see import_cronet.sh
-    origin = folder.origin(),
-    origin_files = cronet_origin_files,
-    destination = git.destination(
-        # The destination URL is set by the invoking script.
-        url = "overwritten/by/script",
-        push = "upstream-import",
-    ),
-    mode = "SQUASH",
-)
diff --git a/Cronet/tools/import/import_cronet.sh b/Cronet/tools/import/import_cronet.sh
deleted file mode 100755
index 0f04af7..0000000
--- a/Cronet/tools/import/import_cronet.sh
+++ /dev/null
@@ -1,146 +0,0 @@
-#!/bin/bash
-
-# Copyright 2023 Google Inc. All rights reserved.
-#
-# 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.
-
-# Script to invoke copybara locally to import Cronet into Android.
-# Inputs:
-#  Environment:
-#   ANDROID_BUILD_TOP: path the root of the current Android directory.
-#  Arguments:
-#   -l rev: The last revision that was imported.
-#  Optional Arguments:
-#   -n rev: The new revision to import.
-#   -f: Force copybara to ignore a failure to find the last imported revision.
-
-set -e -x
-
-OPTSTRING=fl:n:
-
-usage() {
-    cat <<EOF
-Usage: import_cronet.sh -n new-rev [-l last-rev] [-f]
-EOF
-    exit 1
-}
-
-COPYBARA_FOLDER_ORIGIN="/tmp/copybara-origin"
-
-#######################################
-# Create local upstream-import branch in external/cronet.
-# Globals:
-#   ANDROID_BUILD_TOP
-# Arguments:
-#   none
-#######################################
-setup_upstream_import_branch() {
-    local git_dir="${ANDROID_BUILD_TOP}/external/cronet"
-
-    (cd "${git_dir}" && git fetch aosp upstream-import:upstream-import)
-}
-
-#######################################
-# Setup folder.origin for copybara inside /tmp
-# Globals:
-#   COPYBARA_FOLDER_ORIGIN
-# Arguments:
-#   new_rev, string
-#######################################
-setup_folder_origin() (
-    local _new_rev=$1
-    mkdir -p "${COPYBARA_FOLDER_ORIGIN}"
-    cd "${COPYBARA_FOLDER_ORIGIN}"
-
-    if [ -d src ]; then
-        (cd src && git fetch --tags && git checkout "${_new_rev}")
-    else
-        # For this to work _new_rev must be a branch or a tag.
-        git clone --depth=1 --branch "${_new_rev}" https://chromium.googlesource.com/chromium/src.git
-    fi
-
-
-    cat <<EOF >.gclient
-solutions = [
-  {
-    "name": "src",
-    "url": "https://chromium.googlesource.com/chromium/src.git",
-    "managed": False,
-    "custom_deps": {},
-    "custom_vars": {},
-  },
-]
-target_os = ["android"]
-EOF
-    cd src
-    # Set appropriate gclient flags to speed up syncing.
-    gclient sync \
-        --no-history \
-        --shallow \
-        --delete_unversioned_trees
-)
-
-#######################################
-# Runs the copybara import of Chromium
-# Globals:
-#   ANDROID_BUILD_TOP
-#   COPYBARA_FOLDER_ORIGIN
-# Arguments:
-#   last_rev, string or empty
-#   force, string or empty
-#######################################
-do_run_copybara() {
-    local _last_rev=$1
-    local _force=$2
-
-    local -a flags
-    flags+=(--git-destination-url="file://${ANDROID_BUILD_TOP}/external/cronet")
-    flags+=(--repo-timeout 3m)
-
-    # buildtools/third_party/libc++ contains an invalid symlink
-    flags+=(--folder-origin-ignore-invalid-symlinks)
-    flags+=(--git-no-verify)
-
-    if [ ! -z "${_force}" ]; then
-        flags+=(--force)
-    fi
-
-    if [ ! -z "${_last_rev}" ]; then
-        flags+=(--last-rev "${_last_rev}")
-    fi
-
-    /google/bin/releases/copybara/public/copybara/copybara \
-        "${flags[@]}" \
-        "${ANDROID_BUILD_TOP}/packages/modules/Connectivity/Cronet/tools/import/copy.bara.sky" \
-        import_cronet "${COPYBARA_FOLDER_ORIGIN}/src"
-}
-
-while getopts $OPTSTRING opt; do
-    case "${opt}" in
-        f) force=true ;;
-        l) last_rev="${OPTARG}" ;;
-        n) new_rev="${OPTARG}" ;;
-        ?) usage ;;
-        *) echo "'${opt}' '${OPTARG}'"
-    esac
-done
-
-if [ -z "${new_rev}" ]; then
-    echo "-n argument required"
-    usage
-fi
-
-setup_upstream_import_branch
-setup_folder_origin "${new_rev}"
-do_run_copybara "${last_rev}" "${force}"
-
diff --git a/OWNERS_core_networking_xts b/OWNERS_core_networking_xts
index 7612210..b24e3ac 100644
--- a/OWNERS_core_networking_xts
+++ b/OWNERS_core_networking_xts
@@ -3,6 +3,8 @@
 
 # For cherry-picks of CLs that are already merged in aosp/master, or flaky test fixes.
 jchalard@google.com #{LAST_RESORT_SUGGESTION}
+# In addition to cherry-picks and flaky test fixes, also for APF firmware tests
+# (to verify correct behaviour of the wifi APF interpreter)
 maze@google.com #{LAST_RESORT_SUGGESTION}
 # In addition to cherry-picks and flaky test fixes, also for incremental changes on NsdManager tests
 # to increase coverage for existing behavior, and testing of bug fixes in NsdManager
diff --git a/TEST_MAPPING b/TEST_MAPPING
index ab3ed66..d8d4c21 100644
--- a/TEST_MAPPING
+++ b/TEST_MAPPING
@@ -246,6 +246,9 @@
         },
         {
           "exclude-annotation": "com.android.testutils.DnsResolverModuleTest"
+        },
+        {
+          "exclude-annotation": "com.android.testutils.NetworkStackModuleTest"
         }
       ]
     },
diff --git a/Tethering/Android.bp b/Tethering/Android.bp
index e4e6c70..19bcff9 100644
--- a/Tethering/Android.bp
+++ b/Tethering/Android.bp
@@ -74,6 +74,8 @@
         "net-utils-device-common-bpf",
         "net-utils-device-common-ip",
         "net-utils-device-common-netlink",
+        "net-utils-device-common-struct",
+        "net-utils-device-common-struct-base",
         "netd-client",
         "tetheringstatsprotos",
     ],
@@ -98,7 +100,6 @@
     ],
     static_libs: [
         "NetworkStackApiCurrentShims",
-        "net-utils-device-common-struct",
     ],
     apex_available: ["com.android.tethering"],
     lint: {
@@ -115,7 +116,6 @@
     ],
     static_libs: [
         "NetworkStackApiStableShims",
-        "net-utils-device-common-struct",
     ],
     apex_available: ["com.android.tethering"],
     lint: {
diff --git a/Tethering/apex/Android.bp b/Tethering/apex/Android.bp
index 80463db..047ba02 100644
--- a/Tethering/apex/Android.bp
+++ b/Tethering/apex/Android.bp
@@ -100,9 +100,9 @@
         "dscpPolicy.o",
         "netd.o",
         "offload.o",
-        "offload@btf.o",
+        "offload@mainline.o",
         "test.o",
-        "test@btf.o",
+        "test@mainline.o",
     ],
     apps: [
         "ServiceConnectivityResources",
diff --git a/Tethering/common/TetheringLib/Android.bp b/Tethering/common/TetheringLib/Android.bp
index 47227e3..9fa073b 100644
--- a/Tethering/common/TetheringLib/Android.bp
+++ b/Tethering/common/TetheringLib/Android.bp
@@ -36,7 +36,7 @@
         "//frameworks/base/core/tests/bandwidthtests",
         "//frameworks/base/core/tests/benchmarks",
         "//frameworks/base/core/tests/utillib",
-        "//frameworks/base/packages/Connectivity/tests:__subpackages__",
+        "//frameworks/base/services/tests/VpnTests",
         "//frameworks/base/tests/vcn",
         "//frameworks/opt/telephony/tests/telephonytests",
         "//packages/modules/CaptivePortalLogin/tests",
diff --git a/Tethering/res/values-mcc310-mnc004-eu/strings.xml b/Tethering/res/values-mcc310-mnc004-eu/strings.xml
index c970dd7..ff2a505 100644
--- a/Tethering/res/values-mcc310-mnc004-eu/strings.xml
+++ b/Tethering/res/values-mcc310-mnc004-eu/strings.xml
@@ -18,7 +18,7 @@
     xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
     <string name="no_upstream_notification_title" msgid="3584617491053416666">"Konexioa partekatzeko aukerak ez du Interneteko konexiorik"</string>
     <string name="no_upstream_notification_message" msgid="5626323795587558017">"Ezin dira konektatu gailuak"</string>
-    <string name="no_upstream_notification_disable_button" msgid="868677179945695858">"Desaktibatu konexioa partekatzeko aukera"</string>
+    <string name="no_upstream_notification_disable_button" msgid="868677179945695858">"Desaktibatu konexioa partekatzea"</string>
     <string name="upstream_roaming_notification_title" msgid="2870229486619751829">"Wifi-gunea edo konexioa partekatzeko aukera aktibatuta dago"</string>
     <string name="upstream_roaming_notification_message" msgid="5229740963392849544">"Baliteke tarifa gehigarriak ordaindu behar izatea ibiltaritza erabili bitartean"</string>
 </resources>
diff --git a/Tethering/src/android/net/ip/IpServer.java b/Tethering/src/android/net/ip/IpServer.java
index 544ba01..9e0c970 100644
--- a/Tethering/src/android/net/ip/IpServer.java
+++ b/Tethering/src/android/net/ip/IpServer.java
@@ -137,8 +137,8 @@
     private static final MacAddress NULL_MAC_ADDRESS = MacAddress.fromString("00:00:00:00:00:00");
 
     private static final String TAG = "IpServer";
-    private static final boolean DBG = false;
-    private static final boolean VDBG = false;
+    private static final boolean DBG = Log.isLoggable(TAG, Log.DEBUG);
+    private static final boolean VDBG = Log.isLoggable(TAG, Log.VERBOSE);
     private static final Class[] sMessageClasses = {
             IpServer.class
     };
diff --git a/Tethering/src/com/android/networkstack/tethering/Tethering.java b/Tethering/src/com/android/networkstack/tethering/Tethering.java
index 873961a..d85d92f 100644
--- a/Tethering/src/com/android/networkstack/tethering/Tethering.java
+++ b/Tethering/src/com/android/networkstack/tethering/Tethering.java
@@ -173,8 +173,8 @@
 public class Tethering {
 
     private static final String TAG = Tethering.class.getSimpleName();
-    private static final boolean DBG = false;
-    private static final boolean VDBG = false;
+    private static final boolean DBG = Log.isLoggable(TAG, Log.DEBUG);
+    private static final boolean VDBG = Log.isLoggable(TAG, Log.VERBOSE);
 
     private static final Class[] sMessageClasses = {
             Tethering.class, TetherMainSM.class, IpServer.class
@@ -241,9 +241,6 @@
     private final TetherMainSM mTetherMainSM;
     private final OffloadController mOffloadController;
     private final UpstreamNetworkMonitor mUpstreamNetworkMonitor;
-    // TODO: Figure out how to merge this and other downstream-tracking objects
-    // into a single coherent structure.
-    private final HashSet<IpServer> mForwardedDownstreams;
     private final VersionedBroadcastListener mCarrierConfigChange;
     private final TetheringDependencies mDeps;
     private final EntitlementManager mEntitlementMgr;
@@ -271,8 +268,6 @@
 
     private boolean mRndisEnabled;       // track the RNDIS function enabled state
     private boolean mNcmEnabled;         // track the NCM function enabled state
-    // True iff. WiFi tethering should be started when soft AP is ready.
-    private boolean mWifiTetherRequested;
     private Network mTetherUpstream;
     private TetherStatesParcel mTetherStatesParcel;
     private boolean mDataSaverEnabled = false;
@@ -329,7 +324,6 @@
                 (what, obj) -> {
                     mTetherMainSM.sendMessage(TetherMainSM.EVENT_UPSTREAM_CALLBACK, what, 0, obj);
                 });
-        mForwardedDownstreams = new HashSet<>();
 
         IntentFilter filter = new IntentFilter();
         filter.addAction(ACTION_CARRIER_CONFIG_CHANGED);
@@ -763,7 +757,6 @@
             }
             if ((enable && mgr.startTetheredHotspot(null /* use existing softap config */))
                     || (!enable && mgr.stopSoftAp())) {
-                mWifiTetherRequested = enable;
                 return TETHER_ERROR_NO_ERROR;
             }
         } finally {
@@ -1470,10 +1463,6 @@
     }
 
     private void disableWifiIpServing(String ifname, int apState) {
-        // Regardless of whether we requested this transition, the AP has gone
-        // down.  Don't try to tether again unless we're requested to do so.
-        mWifiTetherRequested = false;
-
         mLog.log("Canceling WiFi tethering request - interface=" + ifname + " state=" + apState);
 
         disableWifiIpServingCommon(TETHERING_WIFI, ifname);
@@ -1505,8 +1494,7 @@
     private void enableWifiIpServing(String ifname, int wifiIpMode) {
         mLog.log("request WiFi tethering - interface=" + ifname + " state=" + wifiIpMode);
 
-        // Map wifiIpMode values to IpServer.Callback serving states, inferring
-        // from mWifiTetherRequested as a final "best guess".
+        // Map wifiIpMode values to IpServer.Callback serving states.
         final int ipServingMode;
         switch (wifiIpMode) {
             case IFACE_IP_MODE_TETHERED:
@@ -1653,11 +1641,6 @@
         mLog.log(state.getName() + " got " + sMagicDecoderRing.get(what, Integer.toString(what)));
     }
 
-    private boolean upstreamWanted() {
-        if (!mForwardedDownstreams.isEmpty()) return true;
-        return mWifiTetherRequested;
-    }
-
     // Needed because the canonical source of upstream truth is just the
     // upstream interface set, |mCurrentUpstreamIfaceSet|.
     private boolean pertainsToCurrentUpstream(UpstreamNetworkState ns) {
@@ -1715,12 +1698,16 @@
         private final ArrayList<IpServer> mNotifyList;
         private final IPv6TetheringCoordinator mIPv6TetheringCoordinator;
         private final OffloadWrapper mOffload;
+        // TODO: Figure out how to merge this and other downstream-tracking objects
+        // into a single coherent structure.
+        private final HashSet<IpServer> mForwardedDownstreams;
 
         private static final int UPSTREAM_SETTLE_TIME_MS     = 10000;
 
         TetherMainSM(String name, Looper looper, TetheringDependencies deps) {
             super(name, looper);
 
+            mForwardedDownstreams = new HashSet<>();
             mInitialState = new InitialState();
             mTetherModeAliveState = new TetherModeAliveState();
             mSetIpForwardingEnabledErrorState = new SetIpForwardingEnabledErrorState();
@@ -2056,6 +2043,10 @@
             }
         }
 
+        private boolean upstreamWanted() {
+            return !mForwardedDownstreams.isEmpty();
+        }
+
         class TetherModeAliveState extends State {
             boolean mUpstreamWanted = false;
             boolean mTryCell = true;
@@ -2393,6 +2384,9 @@
                 hasCallingPermission(NETWORK_SETTINGS)
                         || hasCallingPermission(PERMISSION_MAINLINE_NETWORK_STACK)
                         || hasCallingPermission(NETWORK_STACK);
+        if (callback == null) {
+            throw new NullPointerException();
+        }
         mHandler.post(() -> {
             mTetheringEventCallbacks.register(callback, new CallbackCookie(hasListPermission));
             final TetheringCallbackStartedParcel parcel = new TetheringCallbackStartedParcel();
@@ -2651,7 +2645,7 @@
             }
             pw.println(" - lastError = " + tetherState.lastError);
         }
-        pw.println("Upstream wanted: " + upstreamWanted());
+        pw.println("Upstream wanted: " + mTetherMainSM.upstreamWanted());
         pw.println("Current upstream interface(s): " + mCurrentUpstreamIfaceSet);
         pw.decreaseIndent();
 
diff --git a/Tethering/src/com/android/networkstack/tethering/TetheringDependencies.java b/Tethering/src/com/android/networkstack/tethering/TetheringDependencies.java
index 9dfd225..3f86056 100644
--- a/Tethering/src/com/android/networkstack/tethering/TetheringDependencies.java
+++ b/Tethering/src/com/android/networkstack/tethering/TetheringDependencies.java
@@ -156,6 +156,7 @@
     /**
      * Get a reference to BluetoothAdapter to be used by tethering.
      */
+    @Nullable
     public abstract BluetoothAdapter getBluetoothAdapter();
 
     /**
diff --git a/Tethering/src/com/android/networkstack/tethering/TetheringService.java b/Tethering/src/com/android/networkstack/tethering/TetheringService.java
index aa73819..623f502 100644
--- a/Tethering/src/com/android/networkstack/tethering/TetheringService.java
+++ b/Tethering/src/com/android/networkstack/tethering/TetheringService.java
@@ -30,6 +30,7 @@
 
 import android.app.Service;
 import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothManager;
 import android.content.Context;
 import android.content.Intent;
 import android.net.IIntResultListener;
@@ -377,7 +378,11 @@
 
             @Override
             public BluetoothAdapter getBluetoothAdapter() {
-                return BluetoothAdapter.getDefaultAdapter();
+                final BluetoothManager btManager = getSystemService(BluetoothManager.class);
+                if (btManager == null) {
+                    return null;
+                }
+                return btManager.getAdapter();
             }
         };
     }
diff --git a/Tethering/tests/integration/Android.bp b/Tethering/tests/integration/Android.bp
index f17396d..337d408 100644
--- a/Tethering/tests/integration/Android.bp
+++ b/Tethering/tests/integration/Android.bp
@@ -33,6 +33,7 @@
         "net-tests-utils",
         "net-utils-device-common",
         "net-utils-device-common-bpf",
+        "net-utils-device-common-struct-base",
         "testables",
         "connectivity-net-module-utils-bpf",
     ],
@@ -46,7 +47,6 @@
 android_library {
     name: "TetheringIntegrationTestsBaseLib",
     target_sdk_version: "current",
-    platform_apis: true,
     defaults: ["TetheringIntegrationTestsDefaults"],
     visibility: [
         "//packages/modules/Connectivity/Tethering/tests/mts",
@@ -59,7 +59,6 @@
 android_library {
     name: "TetheringIntegrationTestsLatestSdkLib",
     target_sdk_version: "33",
-    platform_apis: true,
     defaults: ["TetheringIntegrationTestsDefaults"],
     srcs: [
         "src/**/*.java",
@@ -76,7 +75,6 @@
 android_library {
     name: "TetheringIntegrationTestsLib",
     target_sdk_version: "current",
-    platform_apis: true,
     defaults: ["TetheringIntegrationTestsDefaults"],
     srcs: [
         "src/**/*.java",
diff --git a/Tethering/tests/integration/base/android/net/EthernetTetheringTestBase.java b/Tethering/tests/integration/base/android/net/EthernetTetheringTestBase.java
index 2933a44..f696885 100644
--- a/Tethering/tests/integration/base/android/net/EthernetTetheringTestBase.java
+++ b/Tethering/tests/integration/base/android/net/EthernetTetheringTestBase.java
@@ -83,8 +83,10 @@
 import java.net.NetworkInterface;
 import java.net.SocketException;
 import java.nio.ByteBuffer;
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.List;
 import java.util.Objects;
 import java.util.Set;
@@ -146,6 +148,8 @@
     private static final TetheringManager sTm = sContext.getSystemService(TetheringManager.class);
     private static final PackageManager sPackageManager = sContext.getPackageManager();
     private static final CtsNetUtils sCtsNetUtils = new CtsNetUtils(sContext);
+    private static final List<String> sCallbackErrors =
+            Collections.synchronizedList(new ArrayList<>());
 
     // Late initialization in setUp()
     private boolean mRunTests;
@@ -172,6 +176,7 @@
         // tests, turn tethering on and off before running them.
         MyTetheringEventCallback callback = null;
         TestNetworkInterface testIface = null;
+        assumeTrue(sEm != null);
         try {
             // If the physical ethernet interface is available, do nothing.
             if (isInterfaceForTetheringAvailable()) return;
@@ -201,6 +206,7 @@
         assumeTrue(mRunTests);
 
         mTetheredInterfaceRequester = new TetheredInterfaceRequester();
+        sCallbackErrors.clear();
     }
 
     private boolean isEthernetTetheringSupported() throws Exception {
@@ -280,6 +286,10 @@
             mHandlerThread.quitSafely();
             mHandlerThread.join();
         }
+
+        if (sCallbackErrors.size() > 0) {
+            fail("Some callbacks had errors: " + sCallbackErrors);
+        }
     }
 
     protected static boolean isInterfaceForTetheringAvailable() throws Exception {
@@ -391,7 +401,7 @@
         }
         @Override
         public void onTetheredInterfacesChanged(List<String> interfaces) {
-            fail("Should only call callback that takes a Set<TetheringInterface>");
+            addCallbackError("Should only call callback that takes a Set<TetheringInterface>");
         }
 
         @Override
@@ -412,7 +422,7 @@
 
         @Override
         public void onLocalOnlyInterfacesChanged(List<String> interfaces) {
-            fail("Should only call callback that takes a Set<TetheringInterface>");
+            addCallbackError("Should only call callback that takes a Set<TetheringInterface>");
         }
 
         @Override
@@ -481,7 +491,7 @@
             // Ignore stale callbacks registered by previous test cases.
             if (mUnregistered) return;
 
-            fail("TetheringEventCallback got error:" + error + " on iface " + ifName);
+            addCallbackError("TetheringEventCallback got error:" + error + " on iface " + ifName);
         }
 
         @Override
@@ -536,6 +546,11 @@
         }
     }
 
+    private static void addCallbackError(String error) {
+        Log.e(TAG, error);
+        sCallbackErrors.add(error);
+    }
+
     protected static MyTetheringEventCallback enableEthernetTethering(String iface,
             TetheringRequest request, Network expectedUpstream) throws Exception {
         // Enable ethernet tethering with null expectedUpstream means the test accept any upstream
@@ -562,7 +577,7 @@
 
             @Override
             public void onTetheringFailed(int resultCode) {
-                fail("Unexpectedly got onTetheringFailed");
+                addCallbackError("Unexpectedly got onTetheringFailed");
             }
         };
         Log.d(TAG, "Starting Ethernet tethering");
diff --git a/Tethering/tests/mts/Android.bp b/Tethering/tests/mts/Android.bp
index a80e49e..c4d5636 100644
--- a/Tethering/tests/mts/Android.bp
+++ b/Tethering/tests/mts/Android.bp
@@ -45,6 +45,7 @@
         "junit-params",
         "connectivity-net-module-utils-bpf",
         "net-utils-device-common-bpf",
+        "net-utils-device-common-struct-base",
     ],
 
     jni_libs: [
diff --git a/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringTest.java b/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringTest.java
index 750bfce..9f430af 100644
--- a/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringTest.java
+++ b/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringTest.java
@@ -3622,6 +3622,42 @@
                 InetAddresses.parseNumericAddress(ifaceConfig.ipv4Addr), ifaceConfig.prefixLength);
         assertFalse(sapPrefix.equals(lohsPrefix));
     }
+
+    @Test
+    public void testWifiTetheringWhenP2pActive() throws Exception {
+        initTetheringOnTestThread();
+        // Enable wifi P2P.
+        sendWifiP2pConnectionChanged(true, true, TEST_P2P_IFNAME);
+        verifyInterfaceServingModeStarted(TEST_P2P_IFNAME);
+        verifyTetheringBroadcast(TEST_P2P_IFNAME, EXTRA_AVAILABLE_TETHER);
+        verifyTetheringBroadcast(TEST_P2P_IFNAME, EXTRA_ACTIVE_LOCAL_ONLY);
+        verify(mUpstreamNetworkMonitor).startObserveAllNetworks();
+        // Verify never enable upstream if only P2P active.
+        verify(mUpstreamNetworkMonitor, never()).setTryCell(true);
+        assertEquals(TETHER_ERROR_NO_ERROR, mTethering.getLastErrorForTest(TEST_P2P_IFNAME));
+
+        when(mWifiManager.startTetheredHotspot(any())).thenReturn(true);
+        // Emulate pressing the WiFi tethering button.
+        mTethering.startTethering(createTetheringRequestParcel(TETHERING_WIFI), TEST_CALLER_PKG,
+                null);
+        mLooper.dispatchAll();
+        verify(mWifiManager).startTetheredHotspot(null);
+        verifyNoMoreInteractions(mWifiManager);
+
+        mTethering.interfaceStatusChanged(TEST_WLAN_IFNAME, true);
+        sendWifiApStateChanged(WIFI_AP_STATE_ENABLED, TEST_WLAN_IFNAME, IFACE_IP_MODE_TETHERED);
+
+        verifyTetheringBroadcast(TEST_WLAN_IFNAME, EXTRA_AVAILABLE_TETHER);
+        verify(mWifiManager).updateInterfaceIpState(
+                TEST_WLAN_IFNAME, WifiManager.IFACE_IP_MODE_UNSPECIFIED);
+
+        verify(mWifiManager).updateInterfaceIpState(TEST_WLAN_IFNAME, IFACE_IP_MODE_TETHERED);
+        verifyNoMoreInteractions(mWifiManager);
+        verifyTetheringBroadcast(TEST_WLAN_IFNAME, EXTRA_ACTIVE_TETHER);
+
+        verify(mUpstreamNetworkMonitor).setTryCell(true);
+    }
+
     // TODO: Test that a request for hotspot mode doesn't interfere with an
     // already operating tethering mode interface.
 }
diff --git a/bpf_progs/Android.bp b/bpf_progs/Android.bp
index 674cd98..1958aa8 100644
--- a/bpf_progs/Android.bp
+++ b/bpf_progs/Android.bp
@@ -94,13 +94,13 @@
 }
 
 bpf {
-    name: "offload@btf.o",
-    srcs: ["offload@btf.c"],
+    name: "offload@mainline.o",
+    srcs: ["offload@mainline.c"],
     btf: true,
     cflags: [
         "-Wall",
         "-Werror",
-        "-DBTF",
+        "-DMAINLINE",
     ],
 }
 
@@ -114,13 +114,13 @@
 }
 
 bpf {
-    name: "test@btf.o",
-    srcs: ["test@btf.c"],
+    name: "test@mainline.o",
+    srcs: ["test@mainline.c"],
     btf: true,
     cflags: [
         "-Wall",
         "-Werror",
-        "-DBTF",
+        "-DMAINLINE",
     ],
 }
 
diff --git a/bpf_progs/block.c b/bpf_progs/block.c
index 0a2b0b8..152dda6 100644
--- a/bpf_progs/block.c
+++ b/bpf_progs/block.c
@@ -19,8 +19,8 @@
 #include <netinet/in.h>
 #include <stdint.h>
 
-// The resulting .o needs to load on the Android T bpfloader
-#define BPFLOADER_MIN_VER BPFLOADER_T_VERSION
+// The resulting .o needs to load on Android T+
+#define BPFLOADER_MIN_VER BPFLOADER_MAINLINE_T_VERSION
 
 #include "bpf_helpers.h"
 
diff --git a/bpf_progs/clatd.c b/bpf_progs/clatd.c
index addb02f..f83e5ae 100644
--- a/bpf_progs/clatd.c
+++ b/bpf_progs/clatd.c
@@ -30,8 +30,8 @@
 #define __kernel_udphdr udphdr
 #include <linux/udp.h>
 
-// The resulting .o needs to load on the Android T bpfloader
-#define BPFLOADER_MIN_VER BPFLOADER_T_VERSION
+// The resulting .o needs to load on Android T+
+#define BPFLOADER_MIN_VER BPFLOADER_MAINLINE_T_VERSION
 
 #include "bpf_helpers.h"
 #include "bpf_net_helpers.h"
@@ -265,6 +265,10 @@
         *(struct iphdr*)data = ip;
     }
 
+    // Count successfully translated packet
+    __sync_fetch_and_add(&v->packets, 1);
+    __sync_fetch_and_add(&v->bytes, skb->len - l2_header_size);
+
     // Redirect, possibly back to same interface, so tcpdump sees packet twice.
     if (v->oif) return bpf_redirect(v->oif, BPF_F_INGRESS);
 
@@ -416,6 +420,10 @@
     // Copy over the new ipv6 header without an ethernet header.
     *(struct ipv6hdr*)data = ip6;
 
+    // Count successfully translated packet
+    __sync_fetch_and_add(&v->packets, 1);
+    __sync_fetch_and_add(&v->bytes, skb->len);
+
     // Redirect to non v4-* interface.  Tcpdump only sees packet after this redirect.
     return bpf_redirect(v->oif, 0 /* this is effectively BPF_F_EGRESS */);
 }
diff --git a/bpf_progs/clatd.h b/bpf_progs/clatd.h
index b5f1cdc..a75798f 100644
--- a/bpf_progs/clatd.h
+++ b/bpf_progs/clatd.h
@@ -39,8 +39,10 @@
 typedef struct {
     uint32_t oif;           // The output interface to redirect to (0 means don't redirect)
     struct in_addr local4;  // The destination IPv4 address
+    uint64_t packets;       // Count of translated gso (large) packets
+    uint64_t bytes;         // Sum of post-translation skb->len
 } ClatIngress6Value;
-STRUCT_SIZE(ClatIngress6Value, 4 + 4);  // 8
+STRUCT_SIZE(ClatIngress6Value, 4 + 4 + 8 + 8);  // 24
 
 typedef struct {
     uint32_t iif;           // The input interface index
@@ -54,7 +56,9 @@
     struct in6_addr pfx96;   // The destination /96 nat64 prefix, bottom 32 bits must be 0
     bool oifIsEthernet;      // Whether the output interface requires ethernet header
     uint8_t pad[3];
+    uint64_t packets;       // Count of translated gso (large) packets
+    uint64_t bytes;         // Sum of post-translation skb->len
 } ClatEgress4Value;
-STRUCT_SIZE(ClatEgress4Value, 4 + 2 * 16 + 1 + 3);  // 40
+STRUCT_SIZE(ClatEgress4Value, 4 + 2 * 16 + 1 + 3 + 8 + 8);  // 56
 
 #undef STRUCT_SIZE
diff --git a/bpf_progs/dscpPolicy.c b/bpf_progs/dscpPolicy.c
index e845a69..ed114e4 100644
--- a/bpf_progs/dscpPolicy.c
+++ b/bpf_progs/dscpPolicy.c
@@ -27,8 +27,8 @@
 #include <stdint.h>
 #include <string.h>
 
-// The resulting .o needs to load on the Android T bpfloader
-#define BPFLOADER_MIN_VER BPFLOADER_T_VERSION
+// The resulting .o needs to load on Android T+
+#define BPFLOADER_MIN_VER BPFLOADER_MAINLINE_T_VERSION
 
 #include "bpf_helpers.h"
 #include "dscpPolicy.h"
diff --git a/bpf_progs/netd.c b/bpf_progs/netd.c
index c4b27b8..dfc7699 100644
--- a/bpf_progs/netd.c
+++ b/bpf_progs/netd.c
@@ -14,8 +14,8 @@
  * limitations under the License.
  */
 
-// The resulting .o needs to load on the Android T bpfloader
-#define BPFLOADER_MIN_VER BPFLOADER_T_VERSION
+// The resulting .o needs to load on Android T+
+#define BPFLOADER_MIN_VER BPFLOADER_MAINLINE_T_VERSION
 
 #include <bpf_helpers.h>
 #include <linux/bpf.h>
@@ -103,13 +103,13 @@
 // A single-element configuration array, packet tracing is enabled when 'true'.
 DEFINE_BPF_MAP_EXT(packet_trace_enabled_map, ARRAY, uint32_t, bool, 1,
                    AID_ROOT, AID_SYSTEM, 0060, "fs_bpf_net_shared", "", PRIVATE,
-                   BPFLOADER_IGNORED_ON_VERSION, BPFLOADER_MAX_VER, LOAD_ON_ENG,
+                   BPFLOADER_MAINLINE_U_VERSION, BPFLOADER_MAX_VER, LOAD_ON_ENG,
                    LOAD_ON_USER, LOAD_ON_USERDEBUG)
 
 // A ring buffer on which packet information is pushed.
 DEFINE_BPF_RINGBUF_EXT(packet_trace_ringbuf, PacketTrace, PACKET_TRACE_BUF_SIZE,
                        AID_ROOT, AID_SYSTEM, 0060, "fs_bpf_net_shared", "", PRIVATE,
-                       BPFLOADER_IGNORED_ON_VERSION, BPFLOADER_MAX_VER, LOAD_ON_ENG,
+                       BPFLOADER_MAINLINE_U_VERSION, BPFLOADER_MAX_VER, LOAD_ON_ENG,
                        LOAD_ON_USER, LOAD_ON_USERDEBUG);
 
 DEFINE_BPF_MAP_RO_NETD(data_saver_enabled_map, ARRAY, uint32_t, bool,
@@ -516,7 +516,7 @@
 // This program is optional, and enables tracing on Android U+, 5.8+ on user builds.
 DEFINE_BPF_PROG_EXT("cgroupskb/ingress/stats$trace_user", AID_ROOT, AID_SYSTEM,
                     bpf_cgroup_ingress_trace_user, KVER_5_8, KVER_INF,
-                    BPFLOADER_IGNORED_ON_VERSION, BPFLOADER_MAX_VER, OPTIONAL,
+                    BPFLOADER_MAINLINE_U_VERSION, BPFLOADER_MAX_VER, OPTIONAL,
                     "fs_bpf_netd_readonly", "",
                     IGNORE_ON_ENG, LOAD_ON_USER, IGNORE_ON_USERDEBUG)
 (struct __sk_buff* skb) {
@@ -526,7 +526,7 @@
 // This program is required, and enables tracing on Android U+, 5.8+, userdebug/eng.
 DEFINE_BPF_PROG_EXT("cgroupskb/ingress/stats$trace", AID_ROOT, AID_SYSTEM,
                     bpf_cgroup_ingress_trace, KVER_5_8, KVER_INF,
-                    BPFLOADER_IGNORED_ON_VERSION, BPFLOADER_MAX_VER, MANDATORY,
+                    BPFLOADER_MAINLINE_U_VERSION, BPFLOADER_MAX_VER, MANDATORY,
                     "fs_bpf_netd_readonly", "",
                     LOAD_ON_ENG, IGNORE_ON_USER, LOAD_ON_USERDEBUG)
 (struct __sk_buff* skb) {
@@ -548,9 +548,9 @@
 // This program is optional, and enables tracing on Android U+, 5.8+ on user builds.
 DEFINE_BPF_PROG_EXT("cgroupskb/egress/stats$trace_user", AID_ROOT, AID_SYSTEM,
                     bpf_cgroup_egress_trace_user, KVER_5_8, KVER_INF,
-                    BPFLOADER_IGNORED_ON_VERSION, BPFLOADER_MAX_VER, OPTIONAL,
+                    BPFLOADER_MAINLINE_U_VERSION, BPFLOADER_MAX_VER, OPTIONAL,
                     "fs_bpf_netd_readonly", "",
-                    LOAD_ON_ENG, IGNORE_ON_USER, LOAD_ON_USERDEBUG)
+                    IGNORE_ON_ENG, LOAD_ON_USER, IGNORE_ON_USERDEBUG)
 (struct __sk_buff* skb) {
     return bpf_traffic_account(skb, EGRESS, TRACE_ON, KVER_5_8);
 }
@@ -558,7 +558,7 @@
 // This program is required, and enables tracing on Android U+, 5.8+, userdebug/eng.
 DEFINE_BPF_PROG_EXT("cgroupskb/egress/stats$trace", AID_ROOT, AID_SYSTEM,
                     bpf_cgroup_egress_trace, KVER_5_8, KVER_INF,
-                    BPFLOADER_IGNORED_ON_VERSION, BPFLOADER_MAX_VER, MANDATORY,
+                    BPFLOADER_MAINLINE_U_VERSION, BPFLOADER_MAX_VER, MANDATORY,
                     "fs_bpf_netd_readonly", "",
                     LOAD_ON_ENG, IGNORE_ON_USER, LOAD_ON_USERDEBUG)
 (struct __sk_buff* skb) {
diff --git a/bpf_progs/offload.c b/bpf_progs/offload.c
index 90f96a1..4f152bf 100644
--- a/bpf_progs/offload.c
+++ b/bpf_progs/offload.c
@@ -24,16 +24,16 @@
 #define __kernel_udphdr udphdr
 #include <linux/udp.h>
 
-#ifdef BTF
+#ifdef MAINLINE
 // BTF is incompatible with bpfloaders < v0.10, hence for S (v0.2) we must
 // ship a different file than for later versions, but we need bpfloader v0.25+
 // for obj@ver.o support
-#define BPFLOADER_MIN_VER BPFLOADER_OBJ_AT_VER_VERSION
-#else /* BTF */
+#define BPFLOADER_MIN_VER BPFLOADER_MAINLINE_T_VERSION
+#else /* MAINLINE */
 // The resulting .o needs to load on the Android S bpfloader
 #define BPFLOADER_MIN_VER BPFLOADER_S_VERSION
-#define BPFLOADER_MAX_VER BPFLOADER_OBJ_AT_VER_VERSION
-#endif /* BTF */
+#define BPFLOADER_MAX_VER BPFLOADER_T_VERSION
+#endif /* MAINLINE */
 
 // Warning: values other than AID_ROOT don't work for map uid on BpfLoader < v0.21
 #define TETHERING_UID AID_ROOT
diff --git a/bpf_progs/offload@btf.c b/bpf_progs/offload@mainline.c
similarity index 100%
rename from bpf_progs/offload@btf.c
rename to bpf_progs/offload@mainline.c
diff --git a/bpf_progs/test.c b/bpf_progs/test.c
index 70b08b7..fff3512 100644
--- a/bpf_progs/test.c
+++ b/bpf_progs/test.c
@@ -18,16 +18,16 @@
 #include <linux/in.h>
 #include <linux/ip.h>
 
-#ifdef BTF
+#ifdef MAINLINE
 // BTF is incompatible with bpfloaders < v0.10, hence for S (v0.2) we must
 // ship a different file than for later versions, but we need bpfloader v0.25+
 // for obj@ver.o support
-#define BPFLOADER_MIN_VER BPFLOADER_OBJ_AT_VER_VERSION
-#else /* BTF */
+#define BPFLOADER_MIN_VER BPFLOADER_MAINLINE_T_VERSION
+#else /* MAINLINE */
 // The resulting .o needs to load on the Android S bpfloader
 #define BPFLOADER_MIN_VER BPFLOADER_S_VERSION
-#define BPFLOADER_MAX_VER BPFLOADER_OBJ_AT_VER_VERSION
-#endif /* BTF */
+#define BPFLOADER_MAX_VER BPFLOADER_T_VERSION
+#endif /* MAINLINE */
 
 // Warning: values other than AID_ROOT don't work for map uid on BpfLoader < v0.21
 #define TETHERING_UID AID_ROOT
diff --git a/bpf_progs/test@btf.c b/bpf_progs/test@mainline.c
similarity index 100%
rename from bpf_progs/test@btf.c
rename to bpf_progs/test@mainline.c
diff --git a/common/Android.bp b/common/Android.bp
index f4b4cae..5fab146 100644
--- a/common/Android.bp
+++ b/common/Android.bp
@@ -24,7 +24,7 @@
 // as the above target may not exist
 // depending on the branch
 
-// The library requires the final artifact to contain net-utils-device-common-struct.
+// The library requires the final artifact to contain net-utils-device-common-struct-base.
 java_library {
     name: "connectivity-net-module-utils-bpf",
     srcs: [
@@ -43,7 +43,7 @@
         // For libraries which are statically linked in framework-connectivity, do not
         // statically link here because callers of this library might already have a static
         // version linked.
-        "net-utils-device-common-struct",
+        "net-utils-device-common-struct-base",
     ],
     apex_available: [
         "com.android.tethering",
diff --git a/common/flags.aconfig b/common/flags.aconfig
index 19b522c..30931df 100644
--- a/common/flags.aconfig
+++ b/common/flags.aconfig
@@ -6,6 +6,7 @@
 
 flag {
   name: "set_data_saver_via_cm"
+  is_exported: true
   namespace: "android_core_networking"
   description: "Set data saver through ConnectivityManager API"
   bug: "297836825"
@@ -13,6 +14,7 @@
 
 flag {
   name: "support_is_uid_networking_blocked"
+  is_exported: true
   namespace: "android_core_networking"
   description: "This flag controls whether isUidNetworkingBlocked is supported"
   bug: "297836825"
@@ -20,6 +22,7 @@
 
 flag {
   name: "basic_background_restrictions_enabled"
+  is_exported: true
   namespace: "android_core_networking"
   description: "Block network access for apps in a low importance background state"
   bug: "304347838"
@@ -27,6 +30,7 @@
 
 flag {
   name: "ipsec_transform_state"
+  is_exported: true
   namespace: "android_core_networking_ipsec"
   description: "The flag controls the access for getIpSecTransformState and IpSecTransformState"
   bug: "308011229"
@@ -34,6 +38,7 @@
 
 flag {
   name: "tethering_request_with_soft_ap_config"
+  is_exported: true
   namespace: "android_core_networking"
   description: "The flag controls the access for the parcelable TetheringRequest with getSoftApConfiguration/setSoftApConfiguration API"
   bug: "216524590"
@@ -41,6 +46,7 @@
 
 flag {
   name: "request_restricted_wifi"
+  is_exported: true
   namespace: "android_core_networking"
   description: "Flag for API to support requesting restricted wifi"
   bug: "315835605"
@@ -48,6 +54,7 @@
 
 flag {
   name: "net_capability_local_network"
+  is_exported: true
   namespace: "android_core_networking"
   description: "Flag for local network capability API"
   bug: "313000440"
@@ -55,6 +62,7 @@
 
 flag {
   name: "support_transport_satellite"
+  is_exported: true
   namespace: "android_core_networking"
   description: "Flag for satellite transport API"
   bug: "320514105"
@@ -62,6 +70,7 @@
 
 flag {
   name: "nsd_subtypes_support_enabled"
+  is_exported: true
   namespace: "android_core_networking"
   description: "Flag for API to support nsd subtypes"
   bug: "265095929"
@@ -69,6 +78,7 @@
 
 flag {
   name: "register_nsd_offload_engine_api"
+  is_exported: true
   namespace: "android_core_networking"
   description: "Flag for API to register nsd offload engine"
   bug: "301713539"
diff --git a/common/nearby_flags.aconfig b/common/nearby_flags.aconfig
index b957d33..b733849 100644
--- a/common/nearby_flags.aconfig
+++ b/common/nearby_flags.aconfig
@@ -3,6 +3,7 @@
 
 flag {
     name: "powered_off_finding"
+    is_exported: true
     namespace: "nearby"
     description: "Controls whether the Powered Off Finding feature is enabled"
     bug: "307898240"
diff --git a/common/src/com/android/net/module/util/bpf/ClatEgress4Value.java b/common/src/com/android/net/module/util/bpf/ClatEgress4Value.java
index 69fab09..71f7516 100644
--- a/common/src/com/android/net/module/util/bpf/ClatEgress4Value.java
+++ b/common/src/com/android/net/module/util/bpf/ClatEgress4Value.java
@@ -36,11 +36,24 @@
     @Field(order = 3, type = Type.U8, padding = 3)
     public final short oifIsEthernet; // Whether the output interface requires ethernet header
 
+    @Field(order = 4, type = Type.U63)
+    public final long packets; // Count of translated gso (large) packets
+
+    @Field(order = 5, type = Type.U63)
+    public final long bytes; // Sum of post-translation skb->len
+
     public ClatEgress4Value(final int oif, final Inet6Address local6, final Inet6Address pfx96,
-            final short oifIsEthernet) {
+            final short oifIsEthernet, final long packets, final long bytes) {
         this.oif = oif;
         this.local6 = local6;
         this.pfx96 = pfx96;
         this.oifIsEthernet = oifIsEthernet;
+        this.packets = packets;
+        this.bytes = bytes;
+    }
+
+    public ClatEgress4Value(final int oif, final Inet6Address local6, final Inet6Address pfx96,
+            final short oifIsEthernet) {
+        this(oif, local6, pfx96, oifIsEthernet, 0, 0);
     }
 }
diff --git a/common/src/com/android/net/module/util/bpf/ClatIngress6Value.java b/common/src/com/android/net/module/util/bpf/ClatIngress6Value.java
index fb81caa..25f737b 100644
--- a/common/src/com/android/net/module/util/bpf/ClatIngress6Value.java
+++ b/common/src/com/android/net/module/util/bpf/ClatIngress6Value.java
@@ -30,8 +30,21 @@
     @Field(order = 1, type = Type.Ipv4Address)
     public final Inet4Address local4; // The destination IPv4 address
 
-    public ClatIngress6Value(final int oif, final Inet4Address local4) {
+    @Field(order = 2, type = Type.U63)
+    public final long packets; // Count of translated gso (large) packets
+
+    @Field(order = 3, type = Type.U63)
+    public final long bytes; // Sum of post-translation skb->len
+
+    public ClatIngress6Value(final int oif, final Inet4Address local4, final long packets,
+            final long bytes) {
         this.oif = oif;
         this.local4 = local4;
+        this.packets = packets;
+        this.bytes = bytes;
+    }
+
+    public ClatIngress6Value(final int oif, final Inet4Address local4) {
+        this(oif, local4, 0, 0);
     }
 }
diff --git a/common/thread_flags.aconfig b/common/thread_flags.aconfig
index 09595a6..43fc147 100644
--- a/common/thread_flags.aconfig
+++ b/common/thread_flags.aconfig
@@ -3,6 +3,7 @@
 
 flag {
     name: "thread_enabled"
+    is_exported: true
     namespace: "thread_network"
     description: "Controls whether the Android Thread feature is enabled"
     bug: "301473012"
diff --git a/framework-t/Android.bp b/framework-t/Android.bp
index 9bcdbdd..0ee2275 100644
--- a/framework-t/Android.bp
+++ b/framework-t/Android.bp
@@ -182,6 +182,7 @@
         "//frameworks/base/core/tests/bandwidthtests",
         "//frameworks/base/core/tests/benchmarks",
         "//frameworks/base/core/tests/utillib",
+        "//frameworks/base/services/tests/VpnTests",
         "//frameworks/base/tests/vcn",
         "//frameworks/opt/net/ethernet/tests:__subpackages__",
         "//frameworks/opt/telephony/tests/telephonytests",
diff --git a/framework-t/src/android/app/usage/NetworkStatsManager.java b/framework-t/src/android/app/usage/NetworkStatsManager.java
index 7fa0661..18c839f 100644
--- a/framework-t/src/android/app/usage/NetworkStatsManager.java
+++ b/framework-t/src/android/app/usage/NetworkStatsManager.java
@@ -752,8 +752,8 @@
     /**
      * Query realtime mobile network usage statistics.
      *
-     * Return a snapshot of current UID network statistics, as it applies
-     * to the mobile radios of the device. The snapshot will include any
+     * Return a snapshot of current UID network statistics for both cellular and satellite (which
+     * also uses same mobile radio as cellular) when called. The snapshot will include any
      * tethering traffic, video calling data usage and count of
      * network operations set by {@link TrafficStats#incrementOperationCount}
      * made over a mobile radio.
diff --git a/framework-t/src/android/net/EthernetManager.java b/framework-t/src/android/net/EthernetManager.java
index b8070f0..719f60d 100644
--- a/framework-t/src/android/net/EthernetManager.java
+++ b/framework-t/src/android/net/EthernetManager.java
@@ -642,7 +642,14 @@
     }
 
     /**
-     * Listen to changes in the state of ethernet.
+     * Register a IntConsumer to be called back on ethernet state changes.
+     *
+     * <p>{@link IntConsumer#accept} with the current ethernet state will be triggered immediately
+     * upon adding a listener. The same callback is invoked on Ethernet state change, i.e. when
+     * calling {@link #setEthernetEnabled}.
+     * <p>The reported state is represented by:
+     * {@link #ETHERNET_STATE_DISABLED}: ethernet is now disabled.
+     * {@link #ETHERNET_STATE_ENABLED}: ethernet is now enabled.
      *
      * @param executor to run callbacks on.
      * @param listener to listen ethernet state changed.
diff --git a/framework-t/src/android/net/nsd/AdvertisingRequest.java b/framework-t/src/android/net/nsd/AdvertisingRequest.java
index b1ef98f..6afb2d5 100644
--- a/framework-t/src/android/net/nsd/AdvertisingRequest.java
+++ b/framework-t/src/android/net/nsd/AdvertisingRequest.java
@@ -17,11 +17,13 @@
 
 import android.annotation.LongDef;
 import android.annotation.NonNull;
+import android.annotation.Nullable;
 import android.os.Parcel;
 import android.os.Parcelable;
 
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
+import java.time.Duration;
 import java.util.Objects;
 
 /**
@@ -34,7 +36,7 @@
     /**
      * Only update the registration without sending exit and re-announcement.
      */
-    public static final int NSD_ADVERTISING_UPDATE_ONLY = 1;
+    public static final long NSD_ADVERTISING_UPDATE_ONLY = 1;
 
 
     @NonNull
@@ -46,7 +48,9 @@
                             NsdServiceInfo.class.getClassLoader(), NsdServiceInfo.class);
                     final int protocolType = in.readInt();
                     final long advertiseConfig = in.readLong();
-                    return new AdvertisingRequest(serviceInfo, protocolType, advertiseConfig);
+                    final long ttlSeconds = in.readLong();
+                    final Duration ttl = ttlSeconds < 0 ? null : Duration.ofSeconds(ttlSeconds);
+                    return new AdvertisingRequest(serviceInfo, protocolType, advertiseConfig, ttl);
                 }
 
                 @Override
@@ -60,6 +64,9 @@
     // Bitmask of @AdvertisingConfig flags. Uses a long to allow 64 possible flags in the future.
     private final long mAdvertisingConfig;
 
+    @Nullable
+    private final Duration mTtl;
+
     /**
      * @hide
      */
@@ -73,10 +80,11 @@
      * The constructor for the advertiseRequest
      */
     private AdvertisingRequest(@NonNull NsdServiceInfo serviceInfo, int protocolType,
-            long advertisingConfig) {
+            long advertisingConfig, @NonNull Duration ttl) {
         mServiceInfo = serviceInfo;
         mProtocolType = protocolType;
         mAdvertisingConfig = advertisingConfig;
+        mTtl = ttl;
     }
 
     /**
@@ -101,12 +109,26 @@
         return mAdvertisingConfig;
     }
 
+    /**
+     * Returns the time interval that the resource records may be cached on a DNS resolver.
+     *
+     * The value will be {@code null} if it's not specified with the {@link #Builder}.
+     *
+     * @hide
+     */
+    // @FlaggedApi(NsdManager.Flags.NSD_CUSTOM_TTL_ENABLED)
+    @Nullable
+    public Duration getTtl() {
+        return mTtl;
+    }
+
     @Override
     public String toString() {
         StringBuilder sb = new StringBuilder();
         sb.append("serviceInfo: ").append(mServiceInfo)
                 .append(", protocolType: ").append(mProtocolType)
-                .append(", advertisingConfig: ").append(mAdvertisingConfig);
+                .append(", advertisingConfig: ").append(mAdvertisingConfig)
+                .append(", ttl: ").append(mTtl);
         return sb.toString();
     }
 
@@ -120,13 +142,14 @@
             final AdvertisingRequest otherRequest = (AdvertisingRequest) other;
             return mServiceInfo.equals(otherRequest.mServiceInfo)
                     && mProtocolType == otherRequest.mProtocolType
-                    && mAdvertisingConfig == otherRequest.mAdvertisingConfig;
+                    && mAdvertisingConfig == otherRequest.mAdvertisingConfig
+                    && Objects.equals(mTtl, otherRequest.mTtl);
         }
     }
 
     @Override
     public int hashCode() {
-        return Objects.hash(mServiceInfo, mProtocolType, mAdvertisingConfig);
+        return Objects.hash(mServiceInfo, mProtocolType, mAdvertisingConfig, mTtl);
     }
 
     @Override
@@ -139,6 +162,7 @@
         dest.writeParcelable(mServiceInfo, flags);
         dest.writeInt(mProtocolType);
         dest.writeLong(mAdvertisingConfig);
+        dest.writeLong(mTtl == null ? -1L : mTtl.getSeconds());
     }
 
 //    @FlaggedApi(NsdManager.Flags.ADVERTISE_REQUEST_API)
@@ -151,6 +175,8 @@
         private final NsdServiceInfo mServiceInfo;
         private final int mProtocolType;
         private long mAdvertisingConfig;
+        @Nullable
+        private Duration mTtl;
         /**
          * Creates a new {@link Builder} object.
          */
@@ -170,11 +196,46 @@
             return this;
         }
 
+        /**
+         * Sets the time interval that the resource records may be cached on a DNS resolver.
+         *
+         * If this method is not called or {@code ttl} is {@code null}, default TTL values
+         * will be used for the service when it's registered. Otherwise, the {@code ttl}
+         * will be used for all resource records of this service.
+         *
+         * When registering a service, {@link NsdManager#FAILURE_BAD_PARAMETERS} will be returned
+         * if {@code ttl} is smaller than 30 seconds.
+         *
+         * Note: the value after the decimal point (in unit of seconds) will be discarded. For
+         * example, {@code 30} seconds will be used when {@code Duration.ofSeconds(30L, 50_000L)}
+         * is provided.
+         *
+         * @param ttl the maximum duration that the DNS resource records will be cached
+         *
+         * @see AdvertisingRequest#getTtl
+         * @hide
+         */
+        // @FlaggedApi(NsdManager.Flags.NSD_CUSTOM_TTL_ENABLED)
+        @NonNull
+        public Builder setTtl(@Nullable Duration ttl) {
+            if (ttl == null) {
+                mTtl = null;
+                return this;
+            }
+            final long ttlSeconds = ttl.getSeconds();
+            if (ttlSeconds < 0 || ttlSeconds > 0xffffffffL) {
+                throw new IllegalArgumentException(
+                        "ttlSeconds exceeds the allowed range (value = " + ttlSeconds
+                                + ", allowedRanged = [0, 0xffffffffL])");
+            }
+            mTtl = Duration.ofSeconds(ttlSeconds);
+            return this;
+        }
 
         /** Creates a new {@link AdvertisingRequest} object. */
         @NonNull
         public AdvertisingRequest build() {
-            return new AdvertisingRequest(mServiceInfo, mProtocolType, mAdvertisingConfig);
+            return new AdvertisingRequest(mServiceInfo, mProtocolType, mAdvertisingConfig, mTtl);
         }
     }
 }
diff --git a/framework-t/src/android/net/nsd/NsdManager.java b/framework-t/src/android/net/nsd/NsdManager.java
index f6e1324..1001423 100644
--- a/framework-t/src/android/net/nsd/NsdManager.java
+++ b/framework-t/src/android/net/nsd/NsdManager.java
@@ -160,6 +160,8 @@
                 "com.android.net.flags.advertise_request_api";
         static final String NSD_CUSTOM_HOSTNAME_ENABLED =
                 "com.android.net.flags.nsd_custom_hostname_enabled";
+        static final String NSD_CUSTOM_TTL_ENABLED =
+                "com.android.net.flags.nsd_custom_ttl_enabled";
     }
 
     /**
@@ -327,6 +329,20 @@
     /** Dns based service discovery protocol */
     public static final int PROTOCOL_DNS_SD = 0x0001;
 
+    /**
+     * The minimum TTL seconds which is allowed for a service registration.
+     *
+     * @hide
+     */
+    public static final long TTL_SECONDS_MIN = 30L;
+
+    /**
+     * The maximum TTL seconds which is allowed for a service registration.
+     *
+     * @hide
+     */
+    public static final long TTL_SECONDS_MAX = 10 * 3600L;
+
     private static final SparseArray<String> EVENT_NAMES = new SparseArray<>();
     static {
         EVENT_NAMES.put(DISCOVER_SERVICES, "DISCOVER_SERVICES");
diff --git a/framework-t/src/android/net/nsd/NsdServiceInfo.java b/framework-t/src/android/net/nsd/NsdServiceInfo.java
index 146d4ca..9491a9c 100644
--- a/framework-t/src/android/net/nsd/NsdServiceInfo.java
+++ b/framework-t/src/android/net/nsd/NsdServiceInfo.java
@@ -16,7 +16,7 @@
 
 package android.net.nsd;
 
-import static java.nio.charset.StandardCharsets.UTF_8;
+import static com.android.net.module.util.HexDump.toHexString;
 
 import android.annotation.FlaggedApi;
 import android.annotation.NonNull;
@@ -35,11 +35,13 @@
 import java.io.UnsupportedEncodingException;
 import java.net.InetAddress;
 import java.nio.charset.StandardCharsets;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.StringJoiner;
 
 /**
  * A class representing service information for network service discovery
@@ -71,6 +73,11 @@
 
     private int mInterfaceIndex;
 
+    // The timestamp that one or more resource records associated with this service are considered
+    // invalid.
+    @Nullable
+    private Instant mExpirationTime;
+
     public NsdServiceInfo() {
         mSubtypes = new ArraySet<>();
         mTxtRecord = new ArrayMap<>();
@@ -99,6 +106,7 @@
         mPort = other.getPort();
         mNetwork = other.getNetwork();
         mInterfaceIndex = other.getInterfaceIndex();
+        mExpirationTime = other.getExpirationTime();
     }
 
     /** Get the service name */
@@ -490,6 +498,40 @@
         return Collections.unmodifiableSet(mSubtypes);
     }
 
+    /**
+     * Sets the timestamp after when this service is expired.
+     *
+     * Note: the value after the decimal point (in unit of seconds) will be discarded. For
+     * example, {@code 30} seconds will be used when {@code Duration.ofSeconds(30L, 50_000L)}
+     * is provided.
+     *
+     * @hide
+     */
+    public void setExpirationTime(@Nullable Instant expirationTime) {
+        if (expirationTime == null) {
+            mExpirationTime = null;
+        } else {
+            mExpirationTime = Instant.ofEpochSecond(expirationTime.getEpochSecond());
+        }
+    }
+
+    /**
+     * Returns the timestamp after when this service is expired or {@code null} if it's unknown.
+     *
+     * A service is considered expired if any of its DNS record is expired.
+     *
+     * Clients that are depending on the refreshness of the service information should not continue
+     * use this service after the returned timestamp. Instead, clients may re-send queries for the
+     * service to get updated the service information.
+     *
+     * @hide
+     */
+    // @FlaggedApi(NsdManager.Flags.NSD_CUSTOM_TTL_ENABLED)
+    @Nullable
+    public Instant getExpirationTime() {
+        return mExpirationTime;
+    }
+
     @Override
     public String toString() {
         StringBuilder sb = new StringBuilder();
@@ -499,13 +541,53 @@
                 .append(", hostAddresses: ").append(TextUtils.join(", ", mHostAddresses))
                 .append(", hostname: ").append(mHostname)
                 .append(", port: ").append(mPort)
-                .append(", network: ").append(mNetwork);
+                .append(", network: ").append(mNetwork)
+                .append(", expirationTime: ").append(mExpirationTime);
 
-        byte[] txtRecord = getTxtRecord();
-        sb.append(", txtRecord: ").append(new String(txtRecord, StandardCharsets.UTF_8));
+        final StringJoiner txtJoiner =
+                new StringJoiner(", " /* delimiter */, "{" /* prefix */, "}" /* suffix */);
+
+        sb.append(", txtRecord: ");
+        for (int i = 0; i < mTxtRecord.size(); i++) {
+            txtJoiner.add(mTxtRecord.keyAt(i) + "=" + getPrintableTxtValue(mTxtRecord.valueAt(i)));
+        }
+        sb.append(txtJoiner.toString());
         return sb.toString();
     }
 
+    /**
+     * Returns printable string for {@code txtValue}.
+     *
+     * If {@code txtValue} contains non-printable ASCII characters, a HEX string with prefix "0x"
+     * will be returned. Otherwise, the ASCII string of {@code txtValue} is returned.
+     *
+     */
+    private static String getPrintableTxtValue(@Nullable byte[] txtValue) {
+        if (txtValue == null) {
+            return "(null)";
+        }
+
+        if (containsNonPrintableChars(txtValue)) {
+            return "0x" + toHexString(txtValue);
+        }
+
+        return new String(txtValue, StandardCharsets.US_ASCII);
+    }
+
+    /**
+     * Returns {@code true} if {@code txtValue} contains non-printable ASCII characters.
+     *
+     * The printable characters are in range of [32, 126].
+     */
+    private static boolean containsNonPrintableChars(byte[] txtValue) {
+        for (int i = 0; i < txtValue.length; i++) {
+            if (txtValue[i] < 32 || txtValue[i] > 126) {
+                return true;
+            }
+        }
+        return false;
+    }
+
     /** Implement the Parcelable interface */
     public int describeContents() {
         return 0;
@@ -539,6 +621,7 @@
             InetAddressUtils.parcelInetAddress(dest, address, flags);
         }
         dest.writeString(mHostname);
+        dest.writeLong(mExpirationTime != null ? mExpirationTime.getEpochSecond() : -1);
     }
 
     /** Implement the Parcelable interface */
@@ -569,6 +652,8 @@
                     info.mHostAddresses.add(InetAddressUtils.unparcelInetAddress(in));
                 }
                 info.mHostname = in.readString();
+                final long seconds = in.readLong();
+                info.setExpirationTime(seconds < 0 ? null : Instant.ofEpochSecond(seconds));
                 return info;
             }
 
diff --git a/framework/Android.bp b/framework/Android.bp
index 8fa336a..deb1c5a 100644
--- a/framework/Android.bp
+++ b/framework/Android.bp
@@ -96,7 +96,7 @@
     ],
     impl_only_static_libs: [
         "net-utils-device-common-bpf",
-        "net-utils-device-common-struct",
+        "net-utils-device-common-struct-base",
     ],
     libs: [
         "androidx.annotation_annotation",
@@ -117,7 +117,6 @@
     static_libs: [
         "httpclient_api",
         "httpclient_impl",
-        "http_client_logging",
         // Framework-connectivity-pre-jarjar is identical to framework-connectivity
         // implementation, but without the jarjar rules. However, framework-connectivity
         // is not based on framework-connectivity-pre-jarjar, it's rebuilt from source
@@ -125,7 +124,7 @@
         // Even if the library is included in "impl_only_static_libs" of defaults. This is still
         // needed because java_library which doesn't understand "impl_only_static_libs".
         "net-utils-device-common-bpf",
-        "net-utils-device-common-struct",
+        "net-utils-device-common-struct-base",
     ],
     libs: [
         // This cannot be in the defaults clause above because if it were, it would be used
@@ -147,7 +146,6 @@
     ],
     impl_only_static_libs: [
         "httpclient_impl",
-        "http_client_logging",
     ],
 }
 
@@ -167,17 +165,16 @@
         "//packages/modules/Connectivity/framework-t",
         "//packages/modules/Connectivity/service",
         "//packages/modules/Connectivity/service-t",
-        "//frameworks/base/packages/Connectivity/service",
         "//frameworks/base",
 
         // Tests using hidden APIs
         "//cts/tests/netlegacy22.api",
         "//cts/tests/tests/app.usage", // NetworkUsageStatsTest
         "//external/sl4a:__subpackages__",
-        "//frameworks/base/packages/Connectivity/tests:__subpackages__",
         "//frameworks/base/core/tests/bandwidthtests",
         "//frameworks/base/core/tests/benchmarks",
         "//frameworks/base/core/tests/utillib",
+        "//frameworks/base/services/tests/VpnTests",
         "//frameworks/base/tests/vcn",
         "//frameworks/opt/net/ethernet/tests:__subpackages__",
         "//frameworks/opt/telephony/tests/telephonytests",
diff --git a/framework/src/android/net/BpfNetMapsReader.java b/framework/src/android/net/BpfNetMapsReader.java
deleted file mode 100644
index ee422ab..0000000
--- a/framework/src/android/net/BpfNetMapsReader.java
+++ /dev/null
@@ -1,287 +0,0 @@
-/*
- * 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 android.net;
-
-import static android.net.BpfNetMapsConstants.CONFIGURATION_MAP_PATH;
-import static android.net.BpfNetMapsConstants.DATA_SAVER_ENABLED;
-import static android.net.BpfNetMapsConstants.DATA_SAVER_ENABLED_KEY;
-import static android.net.BpfNetMapsConstants.DATA_SAVER_ENABLED_MAP_PATH;
-import static android.net.BpfNetMapsConstants.HAPPY_BOX_MATCH;
-import static android.net.BpfNetMapsConstants.PENALTY_BOX_MATCH;
-import static android.net.BpfNetMapsConstants.UID_OWNER_MAP_PATH;
-import static android.net.BpfNetMapsConstants.UID_RULES_CONFIGURATION_KEY;
-import static android.net.BpfNetMapsUtils.getMatchByFirewallChain;
-import static android.net.BpfNetMapsUtils.isFirewallAllowList;
-import static android.net.BpfNetMapsUtils.throwIfPreT;
-import static android.net.ConnectivityManager.FIREWALL_RULE_ALLOW;
-import static android.net.ConnectivityManager.FIREWALL_RULE_DENY;
-
-import android.annotation.NonNull;
-import android.annotation.RequiresApi;
-import android.os.Build;
-import android.os.ServiceSpecificException;
-import android.system.ErrnoException;
-import android.system.Os;
-
-import com.android.internal.annotations.VisibleForTesting;
-import com.android.modules.utils.build.SdkLevel;
-import com.android.net.module.util.BpfMap;
-import com.android.net.module.util.IBpfMap;
-import com.android.net.module.util.Struct.S32;
-import com.android.net.module.util.Struct.U32;
-import com.android.net.module.util.Struct.U8;
-
-/**
- * A helper class to *read* java BpfMaps.
- * @hide
- */
-@RequiresApi(Build.VERSION_CODES.TIRAMISU)  // BPF maps were only mainlined in T
-public class BpfNetMapsReader {
-    private static final String TAG = BpfNetMapsReader.class.getSimpleName();
-
-    // Locally store the handle of bpf maps. The FileDescriptors are statically cached inside the
-    // BpfMap implementation.
-
-    // Bpf map to store various networking configurations, the format of the value is different
-    // for different keys. See BpfNetMapsConstants#*_CONFIGURATION_KEY for keys.
-    private final IBpfMap<S32, U32> mConfigurationMap;
-    // Bpf map to store per uid traffic control configurations.
-    // See {@link UidOwnerValue} for more detail.
-    private final IBpfMap<S32, UidOwnerValue> mUidOwnerMap;
-    private final IBpfMap<S32, U8> mDataSaverEnabledMap;
-    private final Dependencies mDeps;
-
-    // Bitmaps for calculating whether a given uid is blocked by firewall chains.
-    private static final long sMaskDropIfSet;
-    private static final long sMaskDropIfUnset;
-
-    static {
-        long maskDropIfSet = 0L;
-        long maskDropIfUnset = 0L;
-
-        for (int chain : BpfNetMapsConstants.ALLOW_CHAINS) {
-            final long match = getMatchByFirewallChain(chain);
-            maskDropIfUnset |= match;
-        }
-        for (int chain : BpfNetMapsConstants.DENY_CHAINS) {
-            final long match = getMatchByFirewallChain(chain);
-            maskDropIfSet |= match;
-        }
-        sMaskDropIfSet = maskDropIfSet;
-        sMaskDropIfUnset = maskDropIfUnset;
-    }
-
-    private static class SingletonHolder {
-        static final BpfNetMapsReader sInstance = new BpfNetMapsReader();
-    }
-
-    @NonNull
-    public static BpfNetMapsReader getInstance() {
-        return SingletonHolder.sInstance;
-    }
-
-    private BpfNetMapsReader() {
-        this(new Dependencies());
-    }
-
-    // While the production code uses the singleton to optimize for performance and deal with
-    // concurrent access, the test needs to use a non-static approach for dependency injection and
-    // mocking virtual bpf maps.
-    @VisibleForTesting
-    public BpfNetMapsReader(@NonNull Dependencies deps) {
-        if (!SdkLevel.isAtLeastT()) {
-            throw new UnsupportedOperationException(
-                    BpfNetMapsReader.class.getSimpleName() + " is not supported below Android T");
-        }
-        mDeps = deps;
-        mConfigurationMap = mDeps.getConfigurationMap();
-        mUidOwnerMap = mDeps.getUidOwnerMap();
-        mDataSaverEnabledMap = mDeps.getDataSaverEnabledMap();
-    }
-
-    /**
-     * Dependencies of BpfNetMapReader, for injection in tests.
-     */
-    @VisibleForTesting
-    public static class Dependencies {
-        /** Get the configuration map. */
-        public IBpfMap<S32, U32> getConfigurationMap() {
-            try {
-                return new BpfMap<>(CONFIGURATION_MAP_PATH, BpfMap.BPF_F_RDONLY,
-                        S32.class, U32.class);
-            } catch (ErrnoException e) {
-                throw new IllegalStateException("Cannot open configuration map", e);
-            }
-        }
-
-        /** Get the uid owner map. */
-        public IBpfMap<S32, UidOwnerValue> getUidOwnerMap() {
-            try {
-                return new BpfMap<>(UID_OWNER_MAP_PATH, BpfMap.BPF_F_RDONLY,
-                        S32.class, UidOwnerValue.class);
-            } catch (ErrnoException e) {
-                throw new IllegalStateException("Cannot open uid owner map", e);
-            }
-        }
-
-        /** Get the data saver enabled map. */
-        public  IBpfMap<S32, U8> getDataSaverEnabledMap() {
-            try {
-                return new BpfMap<>(DATA_SAVER_ENABLED_MAP_PATH, BpfMap.BPF_F_RDONLY, S32.class,
-                        U8.class);
-            } catch (ErrnoException e) {
-                throw new IllegalStateException("Cannot open data saver enabled map", e);
-            }
-        }
-    }
-
-    /**
-     * Get the specified firewall chain's status.
-     *
-     * @param chain target chain
-     * @return {@code true} if chain is enabled, {@code false} if chain is not enabled.
-     * @throws UnsupportedOperationException if called on pre-T devices.
-     * @throws ServiceSpecificException in case of failure, with an error code indicating the
-     *                                  cause of the failure.
-     */
-    public boolean isChainEnabled(final int chain) {
-        return isChainEnabled(mConfigurationMap, chain);
-    }
-
-    /**
-     * Get firewall rule of specified firewall chain on specified uid.
-     *
-     * @param chain target chain
-     * @param uid        target uid
-     * @return either {@link ConnectivityManager#FIREWALL_RULE_ALLOW} or
-     *         {@link ConnectivityManager#FIREWALL_RULE_DENY}.
-     * @throws UnsupportedOperationException if called on pre-T devices.
-     * @throws ServiceSpecificException in case of failure, with an error code indicating the
-     *                                  cause of the failure.
-     */
-    public int getUidRule(final int chain, final int uid) {
-        return getUidRule(mUidOwnerMap, chain, uid);
-    }
-
-    /**
-     * Get the specified firewall chain's status.
-     *
-     * @param configurationMap target configurationMap
-     * @param chain target chain
-     * @return {@code true} if chain is enabled, {@code false} if chain is not enabled.
-     * @throws UnsupportedOperationException if called on pre-T devices.
-     * @throws ServiceSpecificException in case of failure, with an error code indicating the
-     *                                  cause of the failure.
-     */
-    public static boolean isChainEnabled(
-            final IBpfMap<S32, U32> configurationMap, final int chain) {
-        throwIfPreT("isChainEnabled is not available on pre-T devices");
-
-        final long match = getMatchByFirewallChain(chain);
-        try {
-            final U32 config = configurationMap.getValue(UID_RULES_CONFIGURATION_KEY);
-            return (config.val & match) != 0;
-        } catch (ErrnoException e) {
-            throw new ServiceSpecificException(e.errno,
-                    "Unable to get firewall chain status: " + Os.strerror(e.errno));
-        }
-    }
-
-    /**
-     * Get firewall rule of specified firewall chain on specified uid.
-     *
-     * @param uidOwnerMap target uidOwnerMap.
-     * @param chain target chain.
-     * @param uid target uid.
-     * @return either FIREWALL_RULE_ALLOW or FIREWALL_RULE_DENY
-     * @throws UnsupportedOperationException if called on pre-T devices.
-     * @throws ServiceSpecificException      in case of failure, with an error code indicating the
-     *                                       cause of the failure.
-     */
-    public static int getUidRule(final IBpfMap<S32, UidOwnerValue> uidOwnerMap,
-            final int chain, final int uid) {
-        throwIfPreT("getUidRule is not available on pre-T devices");
-
-        final long match = getMatchByFirewallChain(chain);
-        final boolean isAllowList = isFirewallAllowList(chain);
-        try {
-            final UidOwnerValue uidMatch = uidOwnerMap.getValue(new S32(uid));
-            final boolean isMatchEnabled = uidMatch != null && (uidMatch.rule & match) != 0;
-            return isMatchEnabled == isAllowList ? FIREWALL_RULE_ALLOW : FIREWALL_RULE_DENY;
-        } catch (ErrnoException e) {
-            throw new ServiceSpecificException(e.errno,
-                    "Unable to get uid rule status: " + Os.strerror(e.errno));
-        }
-    }
-
-    /**
-     * Return whether the network is blocked by firewall chains for the given uid.
-     *
-     * @param uid The target uid.
-     * @param isNetworkMetered Whether the target network is metered.
-     * @param isDataSaverEnabled Whether the data saver is enabled.
-     *
-     * @return True if the network is blocked. Otherwise, false.
-     * @throws ServiceSpecificException if the read fails.
-     *
-     * @hide
-     */
-    public boolean isUidNetworkingBlocked(final int uid, boolean isNetworkMetered,
-            boolean isDataSaverEnabled) {
-        throwIfPreT("isUidBlockedByFirewallChains is not available on pre-T devices");
-
-        final long uidRuleConfig;
-        final long uidMatch;
-        try {
-            uidRuleConfig = mConfigurationMap.getValue(UID_RULES_CONFIGURATION_KEY).val;
-            final UidOwnerValue value = mUidOwnerMap.getValue(new S32(uid));
-            uidMatch = (value != null) ? value.rule : 0L;
-        } catch (ErrnoException e) {
-            throw new ServiceSpecificException(e.errno,
-                    "Unable to get firewall chain status: " + Os.strerror(e.errno));
-        }
-
-        final boolean blockedByAllowChains = 0 != (uidRuleConfig & ~uidMatch & sMaskDropIfUnset);
-        final boolean blockedByDenyChains = 0 != (uidRuleConfig & uidMatch & sMaskDropIfSet);
-        if (blockedByAllowChains || blockedByDenyChains) {
-            return true;
-        }
-
-        if (!isNetworkMetered) return false;
-        if ((uidMatch & PENALTY_BOX_MATCH) != 0) return true;
-        if ((uidMatch & HAPPY_BOX_MATCH) != 0) return false;
-        return isDataSaverEnabled;
-    }
-
-    /**
-     * Get Data Saver enabled or disabled
-     *
-     * @return whether Data Saver is enabled or disabled.
-     * @throws ServiceSpecificException in case of failure, with an error code indicating the
-     *                                  cause of the failure.
-     */
-    public boolean getDataSaverEnabled() {
-        throwIfPreT("getDataSaverEnabled is not available on pre-T devices");
-
-        try {
-            return mDataSaverEnabledMap.getValue(DATA_SAVER_ENABLED_KEY).val == DATA_SAVER_ENABLED;
-        } catch (ErrnoException e) {
-            throw new ServiceSpecificException(e.errno, "Unable to get data saver: "
-                    + Os.strerror(e.errno));
-        }
-    }
-}
diff --git a/framework/src/android/net/BpfNetMapsUtils.java b/framework/src/android/net/BpfNetMapsUtils.java
index 0be30bb..19ecafb 100644
--- a/framework/src/android/net/BpfNetMapsUtils.java
+++ b/framework/src/android/net/BpfNetMapsUtils.java
@@ -18,17 +18,22 @@
 
 import static android.net.BpfNetMapsConstants.ALLOW_CHAINS;
 import static android.net.BpfNetMapsConstants.BACKGROUND_MATCH;
+import static android.net.BpfNetMapsConstants.DATA_SAVER_ENABLED;
+import static android.net.BpfNetMapsConstants.DATA_SAVER_ENABLED_KEY;
 import static android.net.BpfNetMapsConstants.DENY_CHAINS;
 import static android.net.BpfNetMapsConstants.DOZABLE_MATCH;
+import static android.net.BpfNetMapsConstants.HAPPY_BOX_MATCH;
 import static android.net.BpfNetMapsConstants.LOW_POWER_STANDBY_MATCH;
 import static android.net.BpfNetMapsConstants.MATCH_LIST;
 import static android.net.BpfNetMapsConstants.NO_MATCH;
 import static android.net.BpfNetMapsConstants.OEM_DENY_1_MATCH;
 import static android.net.BpfNetMapsConstants.OEM_DENY_2_MATCH;
 import static android.net.BpfNetMapsConstants.OEM_DENY_3_MATCH;
+import static android.net.BpfNetMapsConstants.PENALTY_BOX_MATCH;
 import static android.net.BpfNetMapsConstants.POWERSAVE_MATCH;
 import static android.net.BpfNetMapsConstants.RESTRICTED_MATCH;
 import static android.net.BpfNetMapsConstants.STANDBY_MATCH;
+import static android.net.BpfNetMapsConstants.UID_RULES_CONFIGURATION_KEY;
 import static android.net.ConnectivityManager.FIREWALL_CHAIN_BACKGROUND;
 import static android.net.ConnectivityManager.FIREWALL_CHAIN_DOZABLE;
 import static android.net.ConnectivityManager.FIREWALL_CHAIN_LOW_POWER_STANDBY;
@@ -38,12 +43,22 @@
 import static android.net.ConnectivityManager.FIREWALL_CHAIN_POWERSAVE;
 import static android.net.ConnectivityManager.FIREWALL_CHAIN_RESTRICTED;
 import static android.net.ConnectivityManager.FIREWALL_CHAIN_STANDBY;
+import static android.net.ConnectivityManager.FIREWALL_RULE_ALLOW;
+import static android.net.ConnectivityManager.FIREWALL_RULE_DENY;
 import static android.system.OsConstants.EINVAL;
 
+import android.os.Process;
 import android.os.ServiceSpecificException;
+import android.system.ErrnoException;
+import android.system.Os;
 import android.util.Pair;
 
 import com.android.modules.utils.build.SdkLevel;
+import com.android.net.module.util.IBpfMap;
+import com.android.net.module.util.Struct;
+import com.android.net.module.util.Struct.S32;
+import com.android.net.module.util.Struct.U32;
+import com.android.net.module.util.Struct.U8;
 
 import java.util.StringJoiner;
 
@@ -56,6 +71,26 @@
 // Because modules could have different copies of this class if this is statically linked,
 // which would be problematic if the definitions in these modules are not synchronized.
 public class BpfNetMapsUtils {
+    // Bitmaps for calculating whether a given uid is blocked by firewall chains.
+    private static final long sMaskDropIfSet;
+    private static final long sMaskDropIfUnset;
+
+    static {
+        long maskDropIfSet = 0L;
+        long maskDropIfUnset = 0L;
+
+        for (int chain : BpfNetMapsConstants.ALLOW_CHAINS) {
+            final long match = getMatchByFirewallChain(chain);
+            maskDropIfUnset |= match;
+        }
+        for (int chain : BpfNetMapsConstants.DENY_CHAINS) {
+            final long match = getMatchByFirewallChain(chain);
+            maskDropIfSet |= match;
+        }
+        sMaskDropIfSet = maskDropIfSet;
+        sMaskDropIfUnset = maskDropIfUnset;
+    }
+
     // Prevent this class from being accidental instantiated.
     private BpfNetMapsUtils() {}
 
@@ -133,4 +168,128 @@
             throw new UnsupportedOperationException(msg);
         }
     }
+
+    /**
+     * Get the specified firewall chain's status.
+     *
+     * @param configurationMap target configurationMap
+     * @param chain target chain
+     * @return {@code true} if chain is enabled, {@code false} if chain is not enabled.
+     * @throws UnsupportedOperationException if called on pre-T devices.
+     * @throws ServiceSpecificException in case of failure, with an error code indicating the
+     *                                  cause of the failure.
+     */
+    public static boolean isChainEnabled(
+            final IBpfMap<S32, U32> configurationMap, final int chain) {
+        throwIfPreT("isChainEnabled is not available on pre-T devices");
+
+        final long match = getMatchByFirewallChain(chain);
+        try {
+            final U32 config = configurationMap.getValue(UID_RULES_CONFIGURATION_KEY);
+            return (config.val & match) != 0;
+        } catch (ErrnoException e) {
+            throw new ServiceSpecificException(e.errno,
+                    "Unable to get firewall chain status: " + Os.strerror(e.errno));
+        }
+    }
+
+    /**
+     * Get firewall rule of specified firewall chain on specified uid.
+     *
+     * @param uidOwnerMap target uidOwnerMap.
+     * @param chain target chain.
+     * @param uid target uid.
+     * @return either FIREWALL_RULE_ALLOW or FIREWALL_RULE_DENY
+     * @throws UnsupportedOperationException if called on pre-T devices.
+     * @throws ServiceSpecificException      in case of failure, with an error code indicating the
+     *                                       cause of the failure.
+     */
+    public static int getUidRule(final IBpfMap<S32, UidOwnerValue> uidOwnerMap,
+            final int chain, final int uid) {
+        throwIfPreT("getUidRule is not available on pre-T devices");
+
+        final long match = getMatchByFirewallChain(chain);
+        final boolean isAllowList = isFirewallAllowList(chain);
+        try {
+            final UidOwnerValue uidMatch = uidOwnerMap.getValue(new S32(uid));
+            final boolean isMatchEnabled = uidMatch != null && (uidMatch.rule & match) != 0;
+            return isMatchEnabled == isAllowList ? FIREWALL_RULE_ALLOW : FIREWALL_RULE_DENY;
+        } catch (ErrnoException e) {
+            throw new ServiceSpecificException(e.errno,
+                    "Unable to get uid rule status: " + Os.strerror(e.errno));
+        }
+    }
+
+    /**
+     * Return whether the network is blocked by firewall chains for the given uid.
+     *
+     * Note that {@link #getDataSaverEnabled(IBpfMap)} has a latency before V.
+     *
+     * @param uid The target uid.
+     * @param isNetworkMetered Whether the target network is metered.
+     *
+     * @return True if the network is blocked. Otherwise, false.
+     * @throws ServiceSpecificException if the read fails.
+     *
+     * @hide
+     */
+    public static boolean isUidNetworkingBlocked(final int uid, boolean isNetworkMetered,
+            IBpfMap<S32, U32> configurationMap,
+            IBpfMap<S32, UidOwnerValue> uidOwnerMap,
+            IBpfMap<S32, U8> dataSaverEnabledMap
+    ) {
+        throwIfPreT("isUidBlockedByFirewallChains is not available on pre-T devices");
+
+        // System uid is not blocked by firewall chains, see bpf_progs/netd.c
+        // TODO: use UserHandle.isCore() once it is accessible
+        if (uid < Process.FIRST_APPLICATION_UID) {
+            return false;
+        }
+
+        final long uidRuleConfig;
+        final long uidMatch;
+        try {
+            uidRuleConfig = configurationMap.getValue(UID_RULES_CONFIGURATION_KEY).val;
+            final UidOwnerValue value = uidOwnerMap.getValue(new Struct.S32(uid));
+            uidMatch = (value != null) ? value.rule : 0L;
+        } catch (ErrnoException e) {
+            throw new ServiceSpecificException(e.errno,
+                    "Unable to get firewall chain status: " + Os.strerror(e.errno));
+        }
+
+        final boolean blockedByAllowChains = 0 != (uidRuleConfig & ~uidMatch & sMaskDropIfUnset);
+        final boolean blockedByDenyChains = 0 != (uidRuleConfig & uidMatch & sMaskDropIfSet);
+        if (blockedByAllowChains || blockedByDenyChains) {
+            return true;
+        }
+
+        if (!isNetworkMetered) return false;
+        if ((uidMatch & PENALTY_BOX_MATCH) != 0) return true;
+        if ((uidMatch & HAPPY_BOX_MATCH) != 0) return false;
+        return getDataSaverEnabled(dataSaverEnabledMap);
+    }
+
+    /**
+     * Get Data Saver enabled or disabled
+     *
+     * Note that before V, the data saver status in bpf is written by ConnectivityService
+     * when receiving {@link ConnectivityManager#ACTION_RESTRICT_BACKGROUND_CHANGED}. Thus,
+     * the status is not synchronized.
+     * On V+, the data saver status is set by platform code when enabling/disabling
+     * data saver, which is synchronized.
+     *
+     * @return whether Data Saver is enabled or disabled.
+     * @throws ServiceSpecificException in case of failure, with an error code indicating the
+     *                                  cause of the failure.
+     */
+    public static boolean getDataSaverEnabled(IBpfMap<S32, U8> dataSaverEnabledMap) {
+        throwIfPreT("getDataSaverEnabled is not available on pre-T devices");
+
+        try {
+            return dataSaverEnabledMap.getValue(DATA_SAVER_ENABLED_KEY).val == DATA_SAVER_ENABLED;
+        } catch (ErrnoException e) {
+            throw new ServiceSpecificException(e.errno, "Unable to get data saver: "
+                    + Os.strerror(e.errno));
+        }
+    }
 }
diff --git a/framework/src/android/net/ConnectivityManager.java b/framework/src/android/net/ConnectivityManager.java
index 915ec52..b1e636d 100644
--- a/framework/src/android/net/ConnectivityManager.java
+++ b/framework/src/android/net/ConnectivityManager.java
@@ -6284,15 +6284,16 @@
     @RequiresPermission(NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK)
     public boolean isUidNetworkingBlocked(int uid, boolean isNetworkMetered) {
         if (!SdkLevel.isAtLeastU()) {
-            Log.wtf(TAG, "isUidNetworkingBlocked is not supported on pre-U devices");
+            throw new IllegalStateException(
+                    "isUidNetworkingBlocked is not supported on pre-U devices");
         }
-        final BpfNetMapsReader reader = BpfNetMapsReader.getInstance();
+        final NetworkStackBpfNetMaps reader = NetworkStackBpfNetMaps.getInstance();
         // Note that before V, the data saver status in bpf is written by ConnectivityService
         // when receiving {@link #ACTION_RESTRICT_BACKGROUND_CHANGED}. Thus,
         // the status is not synchronized.
         // On V+, the data saver status is set by platform code when enabling/disabling
         // data saver, which is synchronized.
-        return reader.isUidNetworkingBlocked(uid, isNetworkMetered, reader.getDataSaverEnabled());
+        return reader.isUidNetworkingBlocked(uid, isNetworkMetered);
     }
 
     /** @hide */
diff --git a/framework/src/android/net/NetworkCapabilities.java b/framework/src/android/net/NetworkCapabilities.java
index 84a0d29..45efbfe 100644
--- a/framework/src/android/net/NetworkCapabilities.java
+++ b/framework/src/android/net/NetworkCapabilities.java
@@ -749,22 +749,22 @@
      * Network capabilities that are expected to be mutable, i.e., can change while a particular
      * network is connected.
      */
-    private static final long MUTABLE_CAPABILITIES = BitUtils.packBitList(
+    private static final long MUTABLE_CAPABILITIES =
             // TRUSTED can change when user explicitly connects to an untrusted network in Settings.
             // http://b/18206275
-            NET_CAPABILITY_TRUSTED,
-            NET_CAPABILITY_VALIDATED,
-            NET_CAPABILITY_CAPTIVE_PORTAL,
-            NET_CAPABILITY_NOT_ROAMING,
-            NET_CAPABILITY_FOREGROUND,
-            NET_CAPABILITY_NOT_CONGESTED,
-            NET_CAPABILITY_NOT_SUSPENDED,
-            NET_CAPABILITY_PARTIAL_CONNECTIVITY,
-            NET_CAPABILITY_TEMPORARILY_NOT_METERED,
-            NET_CAPABILITY_NOT_VCN_MANAGED,
+            (1L << NET_CAPABILITY_TRUSTED) |
+            (1L << NET_CAPABILITY_VALIDATED) |
+            (1L << NET_CAPABILITY_CAPTIVE_PORTAL) |
+            (1L << NET_CAPABILITY_NOT_ROAMING) |
+            (1L << NET_CAPABILITY_FOREGROUND) |
+            (1L << NET_CAPABILITY_NOT_CONGESTED) |
+            (1L << NET_CAPABILITY_NOT_SUSPENDED) |
+            (1L << NET_CAPABILITY_PARTIAL_CONNECTIVITY) |
+            (1L << NET_CAPABILITY_TEMPORARILY_NOT_METERED) |
+            (1L << NET_CAPABILITY_NOT_VCN_MANAGED) |
             // The value of NET_CAPABILITY_HEAD_UNIT is 32, which cannot use int to do bit shift,
             // otherwise there will be an overflow. Use long to do bit shift instead.
-            NET_CAPABILITY_HEAD_UNIT);
+            (1L << NET_CAPABILITY_HEAD_UNIT);
 
     /**
      * Network capabilities that are not allowed in NetworkRequests. This exists because the
@@ -784,10 +784,10 @@
     /**
      * Capabilities that are set by default when the object is constructed.
      */
-    private static final long DEFAULT_CAPABILITIES = BitUtils.packBitList(
-            NET_CAPABILITY_NOT_RESTRICTED,
-            NET_CAPABILITY_TRUSTED,
-            NET_CAPABILITY_NOT_VPN);
+    private static final long DEFAULT_CAPABILITIES =
+            (1L << NET_CAPABILITY_NOT_RESTRICTED) |
+            (1L << NET_CAPABILITY_TRUSTED) |
+            (1L << NET_CAPABILITY_NOT_VPN);
 
     /**
      * Capabilities that are managed by ConnectivityService.
@@ -795,11 +795,10 @@
      */
     @VisibleForTesting
     public static final long CONNECTIVITY_MANAGED_CAPABILITIES =
-            BitUtils.packBitList(
-                    NET_CAPABILITY_VALIDATED,
-                    NET_CAPABILITY_CAPTIVE_PORTAL,
-                    NET_CAPABILITY_FOREGROUND,
-                    NET_CAPABILITY_PARTIAL_CONNECTIVITY);
+            (1L << NET_CAPABILITY_VALIDATED) |
+            (1L << NET_CAPABILITY_CAPTIVE_PORTAL) |
+            (1L << NET_CAPABILITY_FOREGROUND) |
+            (1L << NET_CAPABILITY_PARTIAL_CONNECTIVITY);
 
     /**
      * Capabilities that are allowed for all test networks. This list must be set so that it is safe
@@ -808,15 +807,14 @@
      * IMS, SUPL, etc.
      */
     private static final long TEST_NETWORKS_ALLOWED_CAPABILITIES =
-            BitUtils.packBitList(
-            NET_CAPABILITY_NOT_METERED,
-            NET_CAPABILITY_TEMPORARILY_NOT_METERED,
-            NET_CAPABILITY_NOT_RESTRICTED,
-            NET_CAPABILITY_NOT_VPN,
-            NET_CAPABILITY_NOT_ROAMING,
-            NET_CAPABILITY_NOT_CONGESTED,
-            NET_CAPABILITY_NOT_SUSPENDED,
-            NET_CAPABILITY_NOT_VCN_MANAGED);
+            (1L << NET_CAPABILITY_NOT_METERED) |
+            (1L << NET_CAPABILITY_TEMPORARILY_NOT_METERED) |
+            (1L << NET_CAPABILITY_NOT_RESTRICTED) |
+            (1L << NET_CAPABILITY_NOT_VPN) |
+            (1L << NET_CAPABILITY_NOT_ROAMING) |
+            (1L << NET_CAPABILITY_NOT_CONGESTED) |
+            (1L << NET_CAPABILITY_NOT_SUSPENDED) |
+            (1L << NET_CAPABILITY_NOT_VCN_MANAGED);
 
     /**
      * Extra allowed capabilities for test networks that do not have TRANSPORT_CELLULAR. Test
@@ -824,7 +822,9 @@
      * the risk of being used by running apps.
      */
     private static final long TEST_NETWORKS_EXTRA_ALLOWED_CAPABILITIES_ON_NON_CELL =
-            BitUtils.packBitList(NET_CAPABILITY_CBS, NET_CAPABILITY_DUN, NET_CAPABILITY_RCS);
+            (1L << NET_CAPABILITY_CBS) |
+            (1L << NET_CAPABILITY_DUN) |
+            (1L << NET_CAPABILITY_RCS);
 
     /**
      * Adds the given capability to this {@code NetworkCapability} instance.
@@ -1174,7 +1174,7 @@
      * @hide
      */
     public void maybeMarkCapabilitiesRestricted() {
-        if (NetworkCapabilitiesUtils.inferRestrictedCapability(this)) {
+        if (NetworkCapabilitiesUtils.inferRestrictedCapability(mNetworkCapabilities)) {
             removeCapability(NET_CAPABILITY_NOT_RESTRICTED);
         }
     }
@@ -1368,12 +1368,11 @@
      * Allowed transports on an unrestricted test network (in addition to TRANSPORT_TEST).
      */
     private static final long UNRESTRICTED_TEST_NETWORKS_ALLOWED_TRANSPORTS =
-            BitUtils.packBitList(
-                    TRANSPORT_TEST,
-                    // Test eth networks are created with EthernetManager#setIncludeTestInterfaces
-                    TRANSPORT_ETHERNET,
-                    // Test VPN networks can be created but their UID ranges must be empty.
-                    TRANSPORT_VPN);
+            (1L << TRANSPORT_TEST) |
+            // Test eth networks are created with EthernetManager#setIncludeTestInterfaces
+            (1L << TRANSPORT_ETHERNET) |
+            // Test VPN networks can be created but their UID ranges must be empty.
+            (1L << TRANSPORT_VPN);
 
     /**
      * Adds the given transport type to this {@code NetworkCapability} instance.
@@ -1775,8 +1774,7 @@
                 // use the same specifier, TelephonyNetworkSpecifier.
                 && mTransportTypes != (1L << TRANSPORT_TEST)
                 && Long.bitCount(mTransportTypes & ~(1L << TRANSPORT_TEST)) != 1
-                && (mTransportTypes & ~(1L << TRANSPORT_TEST))
-                != (1 << TRANSPORT_CELLULAR | 1 << TRANSPORT_SATELLITE)) {
+                && !specifierAcceptableForMultipleTransports(mTransportTypes)) {
             throw new IllegalStateException("Must have a single non-test transport specified to "
                     + "use setNetworkSpecifier");
         }
@@ -1786,6 +1784,12 @@
         return this;
     }
 
+    private boolean specifierAcceptableForMultipleTransports(long transportTypes) {
+        return (transportTypes & ~(1L << TRANSPORT_TEST))
+                // Cellular and satellite use the same NetworkSpecifier.
+                == (1 << TRANSPORT_CELLULAR | 1 << TRANSPORT_SATELLITE);
+    }
+
     /**
      * Sets the optional transport specific information.
      *
diff --git a/framework/src/android/net/NetworkRequest.java b/framework/src/android/net/NetworkRequest.java
index 4de02ac..f7600b2 100644
--- a/framework/src/android/net/NetworkRequest.java
+++ b/framework/src/android/net/NetworkRequest.java
@@ -20,7 +20,6 @@
 import static android.net.NetworkCapabilities.NET_CAPABILITY_DUN;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_FOREGROUND;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET;
-import static android.net.NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_NOT_CONGESTED;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_NOT_METERED;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED;
@@ -41,8 +40,6 @@
 import android.annotation.SuppressLint;
 import android.annotation.SystemApi;
 import android.compat.annotation.UnsupportedAppUsage;
-// TODO : replace with android.net.flags.Flags when aconfig is supported on udc-mainline-prod
-// import android.net.NetworkCapabilities.Flags;
 import android.net.NetworkCapabilities.NetCapability;
 import android.net.NetworkCapabilities.Transport;
 import android.os.Build;
@@ -291,18 +288,6 @@
                 NET_CAPABILITY_TRUSTED,
                 NET_CAPABILITY_VALIDATED);
 
-        /**
-         * Capabilities that are forbidden by default.
-         * Forbidden capabilities only make sense in NetworkRequest, not for network agents.
-         * Therefore these capabilities are only in NetworkRequest.
-         */
-        private static final int[] DEFAULT_FORBIDDEN_CAPABILITIES = new int[] {
-            // TODO(b/313030307): this should contain NET_CAPABILITY_LOCAL_NETWORK.
-            // We cannot currently add it because doing so would crash if the module rolls back,
-            // because JobScheduler persists NetworkRequests to disk, and existing production code
-            // does not consider LOCAL_NETWORK to be a valid capability.
-        };
-
         private final NetworkCapabilities mNetworkCapabilities;
 
         // A boolean that represents whether the NOT_VCN_MANAGED capability should be deduced when
@@ -318,16 +303,6 @@
             // it for apps that do not have the NETWORK_SETTINGS permission.
             mNetworkCapabilities = new NetworkCapabilities();
             mNetworkCapabilities.setSingleUid(Process.myUid());
-            // Default forbidden capabilities are foremost meant to help with backward
-            // compatibility. When adding new types of network identified by a capability that
-            // might confuse older apps, a default forbidden capability will have apps not see
-            // these networks unless they explicitly ask for it.
-            // If the app called clearCapabilities() it will see everything, but then it
-            // can be argued that it's fair to send them too, since it asked for everything
-            // explicitly.
-            for (final int forbiddenCap : DEFAULT_FORBIDDEN_CAPABILITIES) {
-                mNetworkCapabilities.addForbiddenCapability(forbiddenCap);
-            }
         }
 
         /**
diff --git a/framework/src/android/net/NetworkStackBpfNetMaps.java b/framework/src/android/net/NetworkStackBpfNetMaps.java
new file mode 100644
index 0000000..b7c4e34
--- /dev/null
+++ b/framework/src/android/net/NetworkStackBpfNetMaps.java
@@ -0,0 +1,187 @@
+/*
+ * 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 android.net;
+
+import static android.net.BpfNetMapsConstants.CONFIGURATION_MAP_PATH;
+import static android.net.BpfNetMapsConstants.DATA_SAVER_ENABLED_MAP_PATH;
+import static android.net.BpfNetMapsConstants.UID_OWNER_MAP_PATH;
+
+import android.annotation.NonNull;
+import android.annotation.RequiresApi;
+import android.os.Build;
+import android.os.ServiceSpecificException;
+import android.system.ErrnoException;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.modules.utils.build.SdkLevel;
+import com.android.net.module.util.BpfMap;
+import com.android.net.module.util.IBpfMap;
+import com.android.net.module.util.Struct.S32;
+import com.android.net.module.util.Struct.U32;
+import com.android.net.module.util.Struct.U8;
+
+/**
+ * A helper class to *read* java BpfMaps for network stack.
+ * BpfMap operations that are not used from network stack should be in
+ * {@link com.android.server.BpfNetMaps}
+ * @hide
+ */
+// NetworkStack can not use this before U due to b/326143935
+@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+public class NetworkStackBpfNetMaps {
+    private static final String TAG = NetworkStackBpfNetMaps.class.getSimpleName();
+
+    // Locally store the handle of bpf maps. The FileDescriptors are statically cached inside the
+    // BpfMap implementation.
+
+    // Bpf map to store various networking configurations, the format of the value is different
+    // for different keys. See BpfNetMapsConstants#*_CONFIGURATION_KEY for keys.
+    private final IBpfMap<S32, U32> mConfigurationMap;
+    // Bpf map to store per uid traffic control configurations.
+    // See {@link UidOwnerValue} for more detail.
+    private final IBpfMap<S32, UidOwnerValue> mUidOwnerMap;
+    private final IBpfMap<S32, U8> mDataSaverEnabledMap;
+    private final Dependencies mDeps;
+
+    private static class SingletonHolder {
+        static final NetworkStackBpfNetMaps sInstance = new NetworkStackBpfNetMaps();
+    }
+
+    @NonNull
+    public static NetworkStackBpfNetMaps getInstance() {
+        return SingletonHolder.sInstance;
+    }
+
+    private NetworkStackBpfNetMaps() {
+        this(new Dependencies());
+    }
+
+    // While the production code uses the singleton to optimize for performance and deal with
+    // concurrent access, the test needs to use a non-static approach for dependency injection and
+    // mocking virtual bpf maps.
+    @VisibleForTesting
+    public NetworkStackBpfNetMaps(@NonNull Dependencies deps) {
+        if (!SdkLevel.isAtLeastT()) {
+            throw new UnsupportedOperationException(
+                    NetworkStackBpfNetMaps.class.getSimpleName()
+                            + " is not supported below Android T");
+        }
+        mDeps = deps;
+        mConfigurationMap = mDeps.getConfigurationMap();
+        mUidOwnerMap = mDeps.getUidOwnerMap();
+        mDataSaverEnabledMap = mDeps.getDataSaverEnabledMap();
+    }
+
+    /**
+     * Dependencies of BpfNetMapReader, for injection in tests.
+     */
+    @VisibleForTesting
+    public static class Dependencies {
+        /** Get the configuration map. */
+        public IBpfMap<S32, U32> getConfigurationMap() {
+            try {
+                return new BpfMap<>(CONFIGURATION_MAP_PATH, BpfMap.BPF_F_RDONLY,
+                        S32.class, U32.class);
+            } catch (ErrnoException e) {
+                throw new IllegalStateException("Cannot open configuration map", e);
+            }
+        }
+
+        /** Get the uid owner map. */
+        public IBpfMap<S32, UidOwnerValue> getUidOwnerMap() {
+            try {
+                return new BpfMap<>(UID_OWNER_MAP_PATH, BpfMap.BPF_F_RDONLY,
+                        S32.class, UidOwnerValue.class);
+            } catch (ErrnoException e) {
+                throw new IllegalStateException("Cannot open uid owner map", e);
+            }
+        }
+
+        /** Get the data saver enabled map. */
+        public  IBpfMap<S32, U8> getDataSaverEnabledMap() {
+            try {
+                return new BpfMap<>(DATA_SAVER_ENABLED_MAP_PATH, BpfMap.BPF_F_RDONLY, S32.class,
+                        U8.class);
+            } catch (ErrnoException e) {
+                throw new IllegalStateException("Cannot open data saver enabled map", e);
+            }
+        }
+    }
+
+    /**
+     * Get the specified firewall chain's status.
+     *
+     * @param chain target chain
+     * @return {@code true} if chain is enabled, {@code false} if chain is not enabled.
+     * @throws UnsupportedOperationException if called on pre-T devices.
+     * @throws ServiceSpecificException in case of failure, with an error code indicating the
+     *                                  cause of the failure.
+     */
+    public boolean isChainEnabled(final int chain) {
+        return BpfNetMapsUtils.isChainEnabled(mConfigurationMap, chain);
+    }
+
+    /**
+     * Get firewall rule of specified firewall chain on specified uid.
+     *
+     * @param chain target chain
+     * @param uid        target uid
+     * @return either {@link ConnectivityManager#FIREWALL_RULE_ALLOW} or
+     *         {@link ConnectivityManager#FIREWALL_RULE_DENY}.
+     * @throws UnsupportedOperationException if called on pre-T devices.
+     * @throws ServiceSpecificException in case of failure, with an error code indicating the
+     *                                  cause of the failure.
+     */
+    public int getUidRule(final int chain, final int uid) {
+        return BpfNetMapsUtils.getUidRule(mUidOwnerMap, chain, uid);
+    }
+
+    /**
+     * Return whether the network is blocked by firewall chains for the given uid.
+     *
+     * Note that {@link #getDataSaverEnabled()} has a latency before V.
+     *
+     * @param uid The target uid.
+     * @param isNetworkMetered Whether the target network is metered.
+     *
+     * @return True if the network is blocked. Otherwise, false.
+     * @throws ServiceSpecificException if the read fails.
+     *
+     * @hide
+     */
+    public boolean isUidNetworkingBlocked(final int uid, boolean isNetworkMetered) {
+        return BpfNetMapsUtils.isUidNetworkingBlocked(uid, isNetworkMetered,
+                mConfigurationMap, mUidOwnerMap, mDataSaverEnabledMap);
+    }
+
+    /**
+     * Get Data Saver enabled or disabled
+     *
+     * Note that before V, the data saver status in bpf is written by ConnectivityService
+     * when receiving {@link ConnectivityManager#ACTION_RESTRICT_BACKGROUND_CHANGED}. Thus,
+     * the status is not synchronized.
+     * On V+, the data saver status is set by platform code when enabling/disabling
+     * data saver, which is synchronized.
+     *
+     * @return whether Data Saver is enabled or disabled.
+     * @throws ServiceSpecificException in case of failure, with an error code indicating the
+     *                                  cause of the failure.
+     */
+    public boolean getDataSaverEnabled() {
+        return BpfNetMapsUtils.getDataSaverEnabled(mDataSaverEnabledMap);
+    }
+}
diff --git a/framework/src/android/net/connectivity/ConnectivityCompatChanges.java b/framework/src/android/net/connectivity/ConnectivityCompatChanges.java
index dfe5867..a80db85 100644
--- a/framework/src/android/net/connectivity/ConnectivityCompatChanges.java
+++ b/framework/src/android/net/connectivity/ConnectivityCompatChanges.java
@@ -84,6 +84,21 @@
     @ChangeId
     @EnabledAfter(targetSdkVersion = Build.VERSION_CODES.TIRAMISU)
     public static final long ENABLE_PLATFORM_MDNS_BACKEND = 270306772L;
+
+    /**
+     * Apps targeting Android V or higher receive network callbacks from local networks as default
+     *
+     * Apps targeting lower than {@link android.os.Build.VERSION_CODES.VANILLA_ICE_CREAM} need
+     * to add {@link android.net.NetworkCapabilities#NET_CAPABILITY_LOCAL_NETWORK} to the
+     * {@link android.net.NetworkCapabilities} of the {@link android.net.NetworkRequest} to receive
+     * {@link android.net.ConnectivityManager.NetworkCallback} from local networks.
+     *
+     * @hide
+     */
+    @ChangeId
+    @EnabledAfter(targetSdkVersion = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+    public static final long ENABLE_MATCH_LOCAL_NETWORK = 319212206L;
+
     private ConnectivityCompatChanges() {
     }
 }
diff --git a/nearby/service/Android.bp b/nearby/service/Android.bp
index d34fd83..749113d 100644
--- a/nearby/service/Android.bp
+++ b/nearby/service/Android.bp
@@ -43,6 +43,7 @@
     ],
     static_libs: [
         "androidx.core_core",
+        "android.hardware.bluetooth.finder-V1-java",
         "guava",
         "libprotobuf-java-lite",
         "modules-utils-build",
diff --git a/nearby/service/java/com/android/server/nearby/managers/BluetoothFinderManager.java b/nearby/service/java/com/android/server/nearby/managers/BluetoothFinderManager.java
index 63ff516..365b099 100644
--- a/nearby/service/java/com/android/server/nearby/managers/BluetoothFinderManager.java
+++ b/nearby/service/java/com/android/server/nearby/managers/BluetoothFinderManager.java
@@ -16,26 +16,151 @@
 
 package com.android.server.nearby.managers;
 
+import static com.android.server.nearby.NearbyService.TAG;
+
+import android.annotation.TargetApi;
+import android.hardware.bluetooth.finder.Eid;
+import android.hardware.bluetooth.finder.IBluetoothFinder;
 import android.nearby.PoweredOffFindingEphemeralId;
+import android.os.Build;
+import android.os.IBinder;
+import android.os.RemoteException;
+import android.os.ServiceManager;
+import android.os.ServiceSpecificException;
+import android.util.Log;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.modules.utils.build.SdkLevel;
 
 import java.util.List;
 
 /** Connects to {@link IBluetoothFinder} HAL and invokes its API. */
-// A placeholder implementation until the HAL API can be used.
+@TargetApi(Build.VERSION_CODES.TIRAMISU)
 public class BluetoothFinderManager {
 
-    private boolean mPoweredOffFindingModeEnabled = false;
+    private static final String HAL_INSTANCE_NAME = IBluetoothFinder.DESCRIPTOR + "/default";
 
-    /** An empty implementation of the corresponding HAL API call. */
-    public void sendEids(List<PoweredOffFindingEphemeralId> eids) {}
+    private IBluetoothFinder mBluetoothFinder;
+    private IBinder.DeathRecipient mServiceDeathRecipient;
+    private final Object mLock = new Object();
 
-    /** A placeholder implementation of the corresponding HAL API call. */
-    public void setPoweredOffFinderMode(boolean enable) {
-        mPoweredOffFindingModeEnabled = enable;
+    private boolean initBluetoothFinderHal() {
+        final String methodStr = "initBluetoothFinderHal";
+        if (!SdkLevel.isAtLeastV()) return false;
+        synchronized (mLock) {
+            if (mBluetoothFinder != null) {
+                Log.i(TAG, "Bluetooth Finder HAL is already initialized");
+                return true;
+            }
+            try {
+                mBluetoothFinder = getServiceMockable();
+                if (mBluetoothFinder == null) {
+                    Log.e(TAG, "Unable to obtain IBluetoothFinder");
+                    return false;
+                }
+                Log.i(TAG, "Obtained IBluetoothFinder. Local ver: " + IBluetoothFinder.VERSION
+                        + ", Remote ver: " + mBluetoothFinder.getInterfaceVersion());
+
+                IBinder serviceBinder = getServiceBinderMockable();
+                if (serviceBinder == null) {
+                    Log.e(TAG, "Unable to obtain the service binder for IBluetoothFinder");
+                    return false;
+                }
+                mServiceDeathRecipient = new BluetoothFinderDeathRecipient();
+                serviceBinder.linkToDeath(mServiceDeathRecipient, /* flags= */ 0);
+
+                Log.i(TAG, "Bluetooth Finder HAL initialization was successful");
+                return true;
+            } catch (RemoteException e) {
+                handleRemoteException(e, methodStr);
+            } catch (Exception e) {
+                Log.e(TAG, methodStr + " encountered an exception: "  + e);
+            }
+            return false;
+        }
     }
 
-    /** A placeholder implementation of the corresponding HAL API call. */
+    @VisibleForTesting
+    protected IBluetoothFinder getServiceMockable() {
+        return IBluetoothFinder.Stub.asInterface(
+                ServiceManager.waitForDeclaredService(HAL_INSTANCE_NAME));
+    }
+
+    @VisibleForTesting
+    protected IBinder getServiceBinderMockable() {
+        return mBluetoothFinder.asBinder();
+    }
+
+    private class BluetoothFinderDeathRecipient implements IBinder.DeathRecipient {
+        @Override
+        public void binderDied() {
+            Log.e(TAG, "BluetoothFinder service died.");
+            synchronized (mLock) {
+                mBluetoothFinder = null;
+            }
+        }
+    }
+
+    /** See comments for {@link IBluetoothFinder#sendEids(Eid[])} */
+    public void sendEids(List<PoweredOffFindingEphemeralId> eids) {
+        final String methodStr = "sendEids";
+        if (!checkHalAndLogFailure(methodStr)) return;
+        Eid[] eidArray = eids.stream().map(
+                ephmeralId -> {
+                    Eid eid = new Eid();
+                    eid.bytes = ephmeralId.bytes;
+                    return eid;
+                }).toArray(Eid[]::new);
+        try {
+            mBluetoothFinder.sendEids(eidArray);
+        } catch (RemoteException e) {
+            handleRemoteException(e, methodStr);
+        } catch (ServiceSpecificException e) {
+            handleServiceSpecificException(e, methodStr);
+        }
+    }
+
+    /** See comments for {@link IBluetoothFinder#setPoweredOffFinderMode(boolean)} */
+    public void setPoweredOffFinderMode(boolean enable) {
+        final String methodStr = "setPoweredOffMode";
+        if (!checkHalAndLogFailure(methodStr)) return;
+        try {
+            mBluetoothFinder.setPoweredOffFinderMode(enable);
+        } catch (RemoteException e) {
+            handleRemoteException(e, methodStr);
+        } catch (ServiceSpecificException e) {
+            handleServiceSpecificException(e, methodStr);
+        }
+    }
+
+    /** See comments for {@link IBluetoothFinder#getPoweredOffFinderMode()} */
     public boolean getPoweredOffFinderMode() {
-        return mPoweredOffFindingModeEnabled;
+        final String methodStr = "getPoweredOffMode";
+        if (!checkHalAndLogFailure(methodStr)) return false;
+        try {
+            return mBluetoothFinder.getPoweredOffFinderMode();
+        } catch (RemoteException e) {
+            handleRemoteException(e, methodStr);
+        } catch (ServiceSpecificException e) {
+            handleServiceSpecificException(e, methodStr);
+        }
+        return false;
+    }
+
+    private boolean checkHalAndLogFailure(String methodStr) {
+        if ((mBluetoothFinder == null) && !initBluetoothFinderHal()) {
+            Log.e(TAG, "Unable to call " + methodStr + " because IBluetoothFinder is null.");
+            return false;
+        }
+        return true;
+    }
+
+    private void handleRemoteException(RemoteException e, String methodStr) {
+        mBluetoothFinder = null;
+        Log.e(TAG, methodStr + " failed with remote exception: " + e);
+    }
+
+    private void handleServiceSpecificException(ServiceSpecificException e, String methodStr) {
+        Log.e(TAG, methodStr + " failed with service-specific exception: " + e);
     }
 }
diff --git a/nearby/tests/unit/src/com/android/server/nearby/managers/BluetoothFinderManagerTest.java b/nearby/tests/unit/src/com/android/server/nearby/managers/BluetoothFinderManagerTest.java
new file mode 100644
index 0000000..32286e1
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/managers/BluetoothFinderManagerTest.java
@@ -0,0 +1,191 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.nearby.managers;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assume.assumeTrue;
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.anyBoolean;
+import static org.mockito.Mockito.anyInt;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.hardware.bluetooth.finder.Eid;
+import android.hardware.bluetooth.finder.IBluetoothFinder;
+import android.nearby.PoweredOffFindingEphemeralId;
+import android.os.IBinder;
+import android.os.IBinder.DeathRecipient;
+import android.os.RemoteException;
+import android.os.ServiceSpecificException;
+
+import com.android.modules.utils.build.SdkLevel;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.util.List;
+
+public class BluetoothFinderManagerTest {
+    private BluetoothFinderManager mBluetoothFinderManager;
+    private boolean mGetServiceCalled = false;
+
+    @Mock private IBluetoothFinder mIBluetoothFinderMock;
+    @Mock private IBinder mServiceBinderMock;
+
+    private ArgumentCaptor<DeathRecipient> mDeathRecipientCaptor =
+            ArgumentCaptor.forClass(DeathRecipient.class);
+
+    private ArgumentCaptor<Eid[]> mEidArrayCaptor = ArgumentCaptor.forClass(Eid[].class);
+
+    private class BluetoothFinderManagerSpy extends BluetoothFinderManager {
+        @Override
+        protected IBluetoothFinder getServiceMockable() {
+            mGetServiceCalled = true;
+            return mIBluetoothFinderMock;
+        }
+
+        @Override
+        protected IBinder getServiceBinderMockable() {
+            return mServiceBinderMock;
+        }
+    }
+
+    @Before
+    public void setup() {
+        // Replace with minSdkVersion when Build.VERSION_CODES.VANILLA_ICE_CREAM can be used.
+        assumeTrue(SdkLevel.isAtLeastV());
+        MockitoAnnotations.initMocks(this);
+        mBluetoothFinderManager = new BluetoothFinderManagerSpy();
+    }
+
+    @Test
+    public void testSendEids() throws Exception {
+        byte[] eidBytes1 = {
+                (byte) 0xe1, (byte) 0xde, (byte) 0x1d, (byte) 0xe1, (byte) 0xde, (byte) 0x1d,
+                (byte) 0xe1, (byte) 0xde, (byte) 0x1d, (byte) 0xe1, (byte) 0xde, (byte) 0x1d,
+                (byte) 0xe1, (byte) 0xde, (byte) 0x1d, (byte) 0xe1, (byte) 0xde, (byte) 0x1d,
+                (byte) 0xe1, (byte) 0xde
+        };
+        byte[] eidBytes2 = {
+                (byte) 0xf2, (byte) 0xef, (byte) 0x2e, (byte) 0xf2, (byte) 0xef, (byte) 0x2e,
+                (byte) 0xf2, (byte) 0xef, (byte) 0x2e, (byte) 0xf2, (byte) 0xef, (byte) 0x2e,
+                (byte) 0xf2, (byte) 0xef, (byte) 0x2e, (byte) 0xf2, (byte) 0xef, (byte) 0x2e,
+                (byte) 0xf2, (byte) 0xef
+        };
+        PoweredOffFindingEphemeralId ephemeralId1 = new PoweredOffFindingEphemeralId();
+        PoweredOffFindingEphemeralId ephemeralId2 = new PoweredOffFindingEphemeralId();
+        ephemeralId1.bytes = eidBytes1;
+        ephemeralId2.bytes = eidBytes2;
+
+        mBluetoothFinderManager.sendEids(List.of(ephemeralId1, ephemeralId2));
+
+        verify(mIBluetoothFinderMock).sendEids(mEidArrayCaptor.capture());
+        assertThat(mEidArrayCaptor.getValue()[0].bytes).isEqualTo(eidBytes1);
+        assertThat(mEidArrayCaptor.getValue()[1].bytes).isEqualTo(eidBytes2);
+    }
+
+    @Test
+    public void testSendEids_remoteException() throws Exception {
+        doThrow(new RemoteException())
+                .when(mIBluetoothFinderMock).sendEids(any());
+        mBluetoothFinderManager.sendEids(List.of());
+
+        // Verify that we get the service again following a RemoteException.
+        mGetServiceCalled = false;
+        mBluetoothFinderManager.sendEids(List.of());
+        assertThat(mGetServiceCalled).isTrue();
+    }
+
+    @Test
+    public void testSendEids_serviceSpecificException() throws Exception {
+        doThrow(new ServiceSpecificException(1))
+                .when(mIBluetoothFinderMock).sendEids(any());
+        mBluetoothFinderManager.sendEids(List.of());
+    }
+
+    @Test
+    public void testSetPoweredOffFinderMode() throws Exception {
+        mBluetoothFinderManager.setPoweredOffFinderMode(true);
+        verify(mIBluetoothFinderMock).setPoweredOffFinderMode(true);
+
+        mBluetoothFinderManager.setPoweredOffFinderMode(false);
+        verify(mIBluetoothFinderMock).setPoweredOffFinderMode(false);
+    }
+
+    @Test
+    public void testSetPoweredOffFinderMode_remoteException() throws Exception {
+        doThrow(new RemoteException())
+                .when(mIBluetoothFinderMock).setPoweredOffFinderMode(anyBoolean());
+        mBluetoothFinderManager.setPoweredOffFinderMode(true);
+
+        // Verify that we get the service again following a RemoteException.
+        mGetServiceCalled = false;
+        mBluetoothFinderManager.setPoweredOffFinderMode(true);
+        assertThat(mGetServiceCalled).isTrue();
+    }
+
+    @Test
+    public void testSetPoweredOffFinderMode_serviceSpecificException() throws Exception {
+        doThrow(new ServiceSpecificException(1))
+                .when(mIBluetoothFinderMock).setPoweredOffFinderMode(anyBoolean());
+        mBluetoothFinderManager.setPoweredOffFinderMode(true);
+    }
+
+    @Test
+    public void testGetPoweredOffFinderMode() throws Exception {
+        when(mIBluetoothFinderMock.getPoweredOffFinderMode()).thenReturn(true);
+        assertThat(mBluetoothFinderManager.getPoweredOffFinderMode()).isTrue();
+
+        when(mIBluetoothFinderMock.getPoweredOffFinderMode()).thenReturn(false);
+        assertThat(mBluetoothFinderManager.getPoweredOffFinderMode()).isFalse();
+    }
+
+    @Test
+    public void testGetPoweredOffFinderMode_remoteException() throws Exception {
+        when(mIBluetoothFinderMock.getPoweredOffFinderMode()).thenThrow(new RemoteException());
+        assertThat(mBluetoothFinderManager.getPoweredOffFinderMode()).isFalse();
+
+        // Verify that we get the service again following a RemoteException.
+        mGetServiceCalled = false;
+        assertThat(mBluetoothFinderManager.getPoweredOffFinderMode()).isFalse();
+        assertThat(mGetServiceCalled).isTrue();
+    }
+
+    @Test
+    public void testGetPoweredOffFinderMode_serviceSpecificException() throws Exception {
+        when(mIBluetoothFinderMock.getPoweredOffFinderMode())
+                .thenThrow(new ServiceSpecificException(1));
+        assertThat(mBluetoothFinderManager.getPoweredOffFinderMode()).isFalse();
+    }
+
+    @Test
+    public void testDeathRecipient() throws Exception {
+        mBluetoothFinderManager.setPoweredOffFinderMode(true);
+        verify(mServiceBinderMock).linkToDeath(mDeathRecipientCaptor.capture(), anyInt());
+        mDeathRecipientCaptor.getValue().binderDied();
+
+        // Verify that we get the service again following a binder death.
+        mGetServiceCalled = false;
+        mBluetoothFinderManager.setPoweredOffFinderMode(true);
+        assertThat(mGetServiceCalled).isTrue();
+    }
+}
diff --git a/netbpfload/Android.bp b/netbpfload/Android.bp
index b71890e..c39b46c 100644
--- a/netbpfload/Android.bp
+++ b/netbpfload/Android.bp
@@ -18,6 +18,18 @@
     default_team: "trendy_team_fwk_core_networking",
 }
 
+install_symlink {
+    name: "mainline_tethering_platform_components",
+
+    symlink_target: "/apex/com.android.tethering/bin/ethtool",
+    // installed_location is relative to /system because that's the default partition for soong
+    // modules, unless we add something like `system_ext_specific: true` like in hwservicemanager.
+    installed_location: "bin/ethtool",
+
+    init_rc: ["netbpfload.rc"],
+    required: ["bpfloader"],
+}
+
 cc_binary {
     name: "netbpfload",
 
@@ -49,9 +61,7 @@
     // module "netbpfload" variant "android_x86_apex30": should support
     // min_sdk_version(30) for "com.android.tethering": newer SDK(34).
     min_sdk_version: "30",
-
-    init_rc: ["netbpfload.rc"],
-    required: ["bpfloader"],
+    installable: false,
 }
 
 // Versioned netbpfload init rc: init system will process it only on api T/33+ devices
diff --git a/netbpfload/NetBpfLoad.cpp b/netbpfload/NetBpfLoad.cpp
index 2bfaee4..83bb98c 100644
--- a/netbpfload/NetBpfLoad.cpp
+++ b/netbpfload/NetBpfLoad.cpp
@@ -97,7 +97,7 @@
         },
 };
 
-int loadAllElfObjects(const android::bpf::Location& location) {
+int loadAllElfObjects(const unsigned int bpfloader_ver, const android::bpf::Location& location) {
     int retVal = 0;
     DIR* dir;
     struct dirent* ent;
@@ -111,7 +111,7 @@
             progPath += s;
 
             bool critical;
-            int ret = android::bpf::loadProg(progPath.c_str(), &critical, location);
+            int ret = android::bpf::loadProg(progPath.c_str(), &critical, bpfloader_ver, location);
             if (ret) {
                 if (critical) retVal = ret;
                 ALOGE("Failed to load object: %s, ret: %s", progPath.c_str(), std::strerror(-ret));
@@ -171,8 +171,6 @@
 
 #define APEX_MOUNT_POINT "/apex/com.android.tethering"
 const char * const platformBpfLoader = "/system/bin/bpfloader";
-const char * const platformNetBpfLoad = "/system/bin/netbpfload";
-const char * const apexNetBpfLoad = APEX_MOUNT_POINT "/bin/netbpfload";
 
 int logTetheringApexVersion(void) {
     char * found_blockdev = NULL;
@@ -232,12 +230,6 @@
 
     ALOGI("NetBpfLoad '%s' starting...", argv[0]);
 
-    // true iff we are running from the module
-    const bool is_mainline = !strcmp(argv[0], apexNetBpfLoad);
-
-    // true iff we are running from the platform
-    const bool is_platform = !strcmp(argv[0], platformNetBpfLoad);
-
     const int device_api_level = android_get_device_api_level();
     const bool isAtLeastT = (device_api_level >= __ANDROID_API_T__);
     const bool isAtLeastU = (device_api_level >= __ANDROID_API_U__);
@@ -248,22 +240,11 @@
     // first in U QPR2 beta~2
     const bool has_platform_netbpfload_rc = exists("/system/etc/init/netbpfload.rc");
 
-    ALOGI("NetBpfLoad api:%d/%d kver:%07x platform:%d mainline:%d rc:%d%d",
+    ALOGI("NetBpfLoad api:%d/%d kver:%07x rc:%d%d",
           android_get_application_target_sdk_version(), device_api_level,
-          android::bpf::kernelVersion(), is_platform, is_mainline,
+          android::bpf::kernelVersion(),
           has_platform_bpfloader_rc, has_platform_netbpfload_rc);
 
-    if (!is_platform && !is_mainline) {
-        ALOGE("Unable to determine if we're platform or mainline netbpfload.");
-        return 1;
-    }
-
-    if (is_platform) {
-        const char * args[] = { apexNetBpfLoad, NULL, };
-        execve(args[0], (char**)args, envp);
-        ALOGW("exec '%s' fail: %d[%s]", apexNetBpfLoad, errno, strerror(errno));
-    }
-
     if (!has_platform_bpfloader_rc && !has_platform_netbpfload_rc) {
         ALOGE("Unable to find platform's bpfloader & netbpfload init scripts.");
         return 1;
@@ -276,13 +257,8 @@
 
     logTetheringApexVersion();
 
-    if (is_mainline && has_platform_bpfloader_rc && !has_platform_netbpfload_rc) {
-        // Tethering apex shipped initrc file causes us to reach here
-        // but we're not ready to correctly handle anything before U QPR2
-        // in which the 'bpfloader' vs 'netbpfload' split happened
-        const char * args[] = { platformBpfLoader, NULL, };
-        execve(args[0], (char**)args, envp);
-        ALOGE("exec '%s' fail: %d[%s]", platformBpfLoader, errno, strerror(errno));
+    if (!isAtLeastT) {
+        ALOGE("Impossible - not reachable on Android <T.");
         return 1;
     }
 
@@ -337,14 +313,16 @@
         return 1;
     }
 
-    if (isAtLeastU) {
+    if (false && isAtLeastV) {
         // Linux 5.16-rc1 changed the default to 2 (disabled but changeable),
         // but we need 0 (enabled)
         // (this writeFile is known to fail on at least 4.19, but always defaults to 0 on
         // pre-5.13, on 5.13+ it depends on CONFIG_BPF_UNPRIV_DEFAULT_OFF)
         if (writeProcSysFile("/proc/sys/kernel/unprivileged_bpf_disabled", "0\n") &&
             android::bpf::isAtLeastKernelVersion(5, 13, 0)) return 1;
+    }
 
+    if (isAtLeastU) {
         // Enable the eBPF JIT -- but do note that on 64-bit kernels it is likely
         // already force enabled by the kernel config option BPF_JIT_ALWAYS_ON.
         // (Note: this (open) will fail with ENOENT 'No such file or directory' if
@@ -367,9 +345,22 @@
         if (createSysFsBpfSubDir(location.prefix)) return 1;
     }
 
+    // Note: there's no actual src dir for fs_bpf_loader .o's,
+    // so it is not listed in 'locations[].prefix'.
+    // This is because this is primarily meant for triggering genfscon rules,
+    // and as such this will likely always be the case.
+    // Thus we need to manually create the /sys/fs/bpf/loader subdirectory.
+    if (createSysFsBpfSubDir("loader")) return 1;
+
+    // Version of Network BpfLoader depends on the Android OS version
+    unsigned int bpfloader_ver = 42u;  // [42] BPFLOADER_MAINLINE_VERSION
+    if (isAtLeastT) ++bpfloader_ver;   // [43] BPFLOADER_MAINLINE_T_VERSION
+    if (isAtLeastU) ++bpfloader_ver;   // [44] BPFLOADER_MAINLINE_U_VERSION
+    if (isAtLeastV) ++bpfloader_ver;   // [45] BPFLOADER_MAINLINE_V_VERSION
+
     // Load all ELF objects, create programs and maps, and pin them
     for (const auto& location : locations) {
-        if (loadAllElfObjects(location) != 0) {
+        if (loadAllElfObjects(bpfloader_ver, location) != 0) {
             ALOGE("=== CRITICAL FAILURE LOADING BPF PROGRAMS FROM %s ===", location.dir);
             ALOGE("If this triggers reliably, you're probably missing kernel options or patches.");
             ALOGE("If this triggers randomly, you might be hitting some memory allocation "
@@ -389,10 +380,15 @@
         return 1;
     }
 
-    ALOGI("done, transferring control to platform bpfloader.");
+    if (false && isAtLeastV) {
+        ALOGI("done, transferring control to platform bpfloader.");
 
-    const char * args[] = { platformBpfLoader, NULL, };
-    execve(args[0], (char**)args, envp);
-    ALOGE("FATAL: execve('%s'): %d[%s]", platformBpfLoader, errno, strerror(errno));
-    return 1;
+        const char * args[] = { platformBpfLoader, NULL, };
+        execve(args[0], (char**)args, envp);
+        ALOGE("FATAL: execve('%s'): %d[%s]", platformBpfLoader, errno, strerror(errno));
+        return 1;
+    }
+
+    ALOGI("mainline done!");
+    return 0;
 }
diff --git a/netbpfload/loader.cpp b/netbpfload/loader.cpp
index c534b2c..9dd0d2a 100644
--- a/netbpfload/loader.cpp
+++ b/netbpfload/loader.cpp
@@ -31,24 +31,11 @@
 #include <sys/wait.h>
 #include <unistd.h>
 
-// This is BpfLoader v0.41
-// WARNING: If you ever hit cherrypick conflicts here you're doing it wrong:
-// You are NOT allowed to cherrypick bpfloader related patches out of order.
-// (indeed: cherrypicking is probably a bad idea and you should merge instead)
-// Mainline supports ONLY the published versions of the bpfloader for each Android release.
-#define BPFLOADER_VERSION_MAJOR 0u
-#define BPFLOADER_VERSION_MINOR 41u
-#define BPFLOADER_VERSION ((BPFLOADER_VERSION_MAJOR << 16) | BPFLOADER_VERSION_MINOR)
-
 #include "BpfSyscallWrappers.h"
 #include "bpf/BpfUtils.h"
 #include "bpf/bpf_map_def.h"
 #include "loader.h"
 
-#if BPFLOADER_VERSION < COMPILE_FOR_BPFLOADER_VERSION
-#error "BPFLOADER_VERSION is less than COMPILE_FOR_BPFLOADER_VERSION"
-#endif
-
 #include <cstdlib>
 #include <fstream>
 #include <iostream>
@@ -413,9 +400,6 @@
                         size_t sizeOfBpfProgDef) {
     vector<char> pdData;
     int ret = readSectionByName("progs", elfFile, pdData);
-    // Older file formats do not require a 'progs' section at all.
-    // (We should probably figure out whether this is behaviour which is safe to remove now.)
-    if (ret == -2) return 0;
     if (ret) return ret;
 
     if (pdData.size() % sizeOfBpfProgDef) {
@@ -574,6 +558,14 @@
 
 static bool mapMatchesExpectations(const unique_fd& fd, const string& mapName,
                                    const struct bpf_map_def& mapDef, const enum bpf_map_type type) {
+    // bpfGetFd... family of functions require at minimum a 4.14 kernel,
+    // so on 4.9-T kernels just pretend the map matches our expectations.
+    // Additionally we'll get almost equivalent test coverage on newer devices/kernels.
+    // This is because the primary failure mode we're trying to detect here
+    // is either a source code misconfiguration (which is likely kernel independent)
+    // or a newly introduced kernel feature/bug (which is unlikely to get backported to 4.9).
+    if (!isAtLeastKernelVersion(4, 14, 0)) return true;
+
     // Assuming fd is a valid Bpf Map file descriptor then
     // all the following should always succeed on a 4.14+ kernel.
     // If they somehow do fail, they'll return -1 (and set errno),
@@ -621,7 +613,8 @@
 }
 
 static int createMaps(const char* elfPath, ifstream& elfFile, vector<unique_fd>& mapFds,
-                      const char* prefix, const size_t sizeOfBpfMapDef) {
+                      const char* prefix, const size_t sizeOfBpfMapDef,
+                      const unsigned int bpfloader_ver) {
     int ret;
     vector<char> mdData;
     vector<struct bpf_map_def> md;
@@ -663,14 +656,14 @@
     for (int i = 0; i < (int)mapNames.size(); i++) {
         if (md[i].zero != 0) abort();
 
-        if (BPFLOADER_VERSION < md[i].bpfloader_min_ver) {
+        if (bpfloader_ver < md[i].bpfloader_min_ver) {
             ALOGI("skipping map %s which requires bpfloader min ver 0x%05x", mapNames[i].c_str(),
                   md[i].bpfloader_min_ver);
             mapFds.push_back(unique_fd());
             continue;
         }
 
-        if (BPFLOADER_VERSION >= md[i].bpfloader_max_ver) {
+        if (bpfloader_ver >= md[i].bpfloader_max_ver) {
             ALOGI("skipping map %s which requires bpfloader max ver 0x%05x", mapNames[i].c_str(),
                   md[i].bpfloader_max_ver);
             mapFds.push_back(unique_fd());
@@ -711,6 +704,16 @@
         }
 
         enum bpf_map_type type = md[i].type;
+        if (type == BPF_MAP_TYPE_DEVMAP && !isAtLeastKernelVersion(4, 14, 0)) {
+            // On Linux Kernels older than 4.14 this map type doesn't exist, but it can kind
+            // of be approximated: ARRAY has the same userspace api, though it is not usable
+            // by the same ebpf programs.  However, that's okay because the bpf_redirect_map()
+            // helper doesn't exist on 4.9-T anyway (so the bpf program would fail to load,
+            // and thus needs to be tagged as 4.14+ either way), so there's nothing useful you
+            // could do with a DEVMAP anyway (that isn't already provided by an ARRAY)...
+            // Hence using an ARRAY instead of a DEVMAP simply makes life easier for userspace.
+            type = BPF_MAP_TYPE_ARRAY;
+        }
         if (type == BPF_MAP_TYPE_DEVMAP_HASH && !isAtLeastKernelVersion(5, 4, 0)) {
             // On Linux Kernels older than 5.4 this map type doesn't exist, but it can kind
             // of be approximated: HASH has the same userspace visible api.
@@ -766,7 +769,8 @@
               .max_entries = max_entries,
               .map_flags = md[i].map_flags,
             };
-            strlcpy(req.map_name, mapNames[i].c_str(), sizeof(req.map_name));
+            if (isAtLeastKernelVersion(4, 14, 0))
+                strlcpy(req.map_name, mapNames[i].c_str(), sizeof(req.map_name));
             fd.reset(bpf(BPF_MAP_CREATE, req));
             saved_errno = errno;
             ALOGD("bpf_create_map name %s, ret: %d", mapNames[i].c_str(), fd.get());
@@ -910,7 +914,7 @@
 }
 
 static int loadCodeSections(const char* elfPath, vector<codeSection>& cs, const string& license,
-                            const char* prefix) {
+                            const char* prefix, const unsigned int bpfloader_ver) {
     unsigned kvers = kernelVersion();
 
     if (!kvers) {
@@ -946,8 +950,8 @@
 
         ALOGD("cs[%d].name:%s requires bpfloader version [0x%05x,0x%05x)", i, name.c_str(),
               bpfMinVer, bpfMaxVer);
-        if (BPFLOADER_VERSION < bpfMinVer) continue;
-        if (BPFLOADER_VERSION >= bpfMaxVer) continue;
+        if (bpfloader_ver < bpfMinVer) continue;
+        if (bpfloader_ver >= bpfMaxVer) continue;
 
         if ((cs[i].prog_def->ignore_on_eng && isEng()) ||
             (cs[i].prog_def->ignore_on_user && isUser()) ||
@@ -1008,7 +1012,8 @@
               .log_size = static_cast<__u32>(log_buf.size()),
               .expected_attach_type = cs[i].expected_attach_type,
             };
-            strlcpy(req.prog_name, cs[i].name.c_str(), sizeof(req.prog_name));
+            if (isAtLeastKernelVersion(4, 14, 0))
+                strlcpy(req.prog_name, cs[i].name.c_str(), sizeof(req.prog_name));
             fd.reset(bpf(BPF_PROG_LOAD, req));
 
             ALOGD("BPF_PROG_LOAD call for %s (%s) returned fd: %d (%s)", elfPath,
@@ -1082,7 +1087,8 @@
     return 0;
 }
 
-int loadProg(const char* elfPath, bool* isCritical, const Location& location) {
+int loadProg(const char* const elfPath, bool* const isCritical, const unsigned int bpfloader_ver,
+             const Location& location) {
     vector<char> license;
     vector<char> critical;
     vector<codeSection> cs;
@@ -1121,27 +1127,27 @@
             readSectionUint("size_of_bpf_prog_def", elfFile, DEFAULT_SIZEOF_BPF_PROG_DEF);
 
     // inclusive lower bound check
-    if (BPFLOADER_VERSION < bpfLoaderMinVer) {
+    if (bpfloader_ver < bpfLoaderMinVer) {
         ALOGI("BpfLoader version 0x%05x ignoring ELF object %s with min ver 0x%05x",
-              BPFLOADER_VERSION, elfPath, bpfLoaderMinVer);
+              bpfloader_ver, elfPath, bpfLoaderMinVer);
         return 0;
     }
 
     // exclusive upper bound check
-    if (BPFLOADER_VERSION >= bpfLoaderMaxVer) {
+    if (bpfloader_ver >= bpfLoaderMaxVer) {
         ALOGI("BpfLoader version 0x%05x ignoring ELF object %s with max ver 0x%05x",
-              BPFLOADER_VERSION, elfPath, bpfLoaderMaxVer);
+              bpfloader_ver, elfPath, bpfLoaderMaxVer);
         return 0;
     }
 
-    if (BPFLOADER_VERSION < bpfLoaderMinRequiredVer) {
+    if (bpfloader_ver < bpfLoaderMinRequiredVer) {
         ALOGI("BpfLoader version 0x%05x failing due to ELF object %s with required min ver 0x%05x",
-              BPFLOADER_VERSION, elfPath, bpfLoaderMinRequiredVer);
+              bpfloader_ver, elfPath, bpfLoaderMinRequiredVer);
         return -1;
     }
 
     ALOGI("BpfLoader version 0x%05x processing ELF object %s with ver [0x%05x,0x%05x)",
-          BPFLOADER_VERSION, elfPath, bpfLoaderMinVer, bpfLoaderMaxVer);
+          bpfloader_ver, elfPath, bpfLoaderMinVer, bpfLoaderMaxVer);
 
     if (sizeOfBpfMapDef < DEFAULT_SIZEOF_BPF_MAP_DEF) {
         ALOGE("sizeof(bpf_map_def) of %zu is too small (< %d)", sizeOfBpfMapDef,
@@ -1164,7 +1170,7 @@
     /* Just for future debugging */
     if (0) dumpAllCs(cs);
 
-    ret = createMaps(elfPath, elfFile, mapFds, location.prefix, sizeOfBpfMapDef);
+    ret = createMaps(elfPath, elfFile, mapFds, location.prefix, sizeOfBpfMapDef, bpfloader_ver);
     if (ret) {
         ALOGE("Failed to create maps: (ret=%d) in %s", ret, elfPath);
         return ret;
@@ -1175,7 +1181,7 @@
 
     applyMapRelo(elfFile, mapFds, cs);
 
-    ret = loadCodeSections(elfPath, cs, string(license.data()), location.prefix);
+    ret = loadCodeSections(elfPath, cs, string(license.data()), location.prefix, bpfloader_ver);
     if (ret) ALOGE("Failed to load programs, loadCodeSections ret=%d", ret);
 
     return ret;
diff --git a/netbpfload/loader.h b/netbpfload/loader.h
index b884637..4da6830 100644
--- a/netbpfload/loader.h
+++ b/netbpfload/loader.h
@@ -70,7 +70,8 @@
 };
 
 // BPF loader implementation. Loads an eBPF ELF object
-int loadProg(const char* elfPath, bool* isCritical, const Location &location = {});
+int loadProg(const char* elfPath, bool* isCritical, const unsigned int bpfloader_ver,
+             const Location &location = {});
 
 // Exposed for testing
 unsigned int readSectionUint(const char* name, std::ifstream& elfFile, unsigned int defVal);
diff --git a/netbpfload/netbpfload.mainline.rc b/netbpfload/netbpfload.mainline.rc
index 0ac5de8..d38a503 100644
--- a/netbpfload/netbpfload.mainline.rc
+++ b/netbpfload/netbpfload.mainline.rc
@@ -1,8 +1,17 @@
-service bpfloader /apex/com.android.tethering/bin/netbpfload
+service mdnsd_loadbpf /system/bin/bpfloader
     capabilities CHOWN SYS_ADMIN NET_ADMIN
     group root graphics network_stack net_admin net_bw_acct net_bw_stats net_raw system
     user root
     rlimit memlock 1073741824 1073741824
     oneshot
     reboot_on_failure reboot,bpfloader-failed
+
+service bpfloader /apex/com.android.tethering/bin/netbpfload
+    capabilities CHOWN SYS_ADMIN NET_ADMIN
+    group system root graphics network_stack net_admin net_bw_acct net_bw_stats net_raw
+    user system
+    file /dev/kmsg w
+    rlimit memlock 1073741824 1073741824
+    oneshot
+    reboot_on_failure reboot,bpfloader-failed
     override
diff --git a/netbpfload/netbpfload.rc b/netbpfload/netbpfload.rc
index 14181dc..e1af47f 100644
--- a/netbpfload/netbpfload.rc
+++ b/netbpfload/netbpfload.rc
@@ -17,15 +17,18 @@
 on load_bpf_programs
     exec_start bpfloader
 
-service bpfloader /system/bin/netbpfload
+# Note: This will actually execute /apex/com.android.tethering/bin/netbpfload
+# by virtue of 'service bpfloader' being overridden by the apex shipped .rc
+# Warning: most of the below settings are irrelevant unless the apex is missing.
+service bpfloader /system/bin/false
     # netbpfload will do network bpf loading, then execute /system/bin/bpfloader
-    capabilities CHOWN SYS_ADMIN NET_ADMIN
+    #! capabilities CHOWN SYS_ADMIN NET_ADMIN
     # The following group memberships are a workaround for lack of DAC_OVERRIDE
     # and allow us to open (among other things) files that we created and are
     # no longer root owned (due to CHOWN) but still have group read access to
     # one of the following groups.  This is not perfect, but a more correct
     # solution requires significantly more effort to implement.
-    group root graphics network_stack net_admin net_bw_acct net_bw_stats net_raw system
+    #! group root graphics network_stack net_admin net_bw_acct net_bw_stats net_raw system
     user root
     #
     # Set RLIMIT_MEMLOCK to 1GiB for bpfloader
@@ -55,7 +58,7 @@
     #
     # As such we simply use 1GiB as a reasonable approximation of infinity.
     #
-    rlimit memlock 1073741824 1073741824
+    #! rlimit memlock 1073741824 1073741824
     oneshot
     #
     # How to debug bootloops caused by 'bpfloader-failed'.
@@ -81,6 +84,5 @@
     #    'cannot prove return value is 0 or 1' or 'unsupported / unknown operation / helper',
     #    'invalid bpf_context access', etc.
     #
-    reboot_on_failure reboot,bpfloader-failed
-    # we're not really updatable, but want to be able to load bpf programs shipped in apexes
+    reboot_on_failure reboot,netbpfload-missing
     updatable
diff --git a/netd/BpfHandler.cpp b/netd/BpfHandler.cpp
index a00c363..0d75c05 100644
--- a/netd/BpfHandler.cpp
+++ b/netd/BpfHandler.cpp
@@ -165,9 +165,38 @@
 BpfHandler::BpfHandler(uint32_t perUidLimit, uint32_t totalLimit)
     : mPerUidStatsEntriesLimit(perUidLimit), mTotalUidStatsEntriesLimit(totalLimit) {}
 
+// copied with minor changes from waitForProgsLoaded()
+// p/m/C's staticlibs/native/bpf_headers/include/bpf/WaitForProgsLoaded.h
+static inline void waitForNetProgsLoaded() {
+    // infinite loop until success with 5/10/20/40/60/60/60... delay
+    for (int delay = 5;; delay *= 2) {
+        if (delay > 60) delay = 60;
+        if (base::WaitForProperty("init.svc.bpfloader", "stopped", std::chrono::seconds(delay))
+            && !access("/sys/fs/bpf/netd_shared", F_OK))
+            return;
+        ALOGW("Waited %ds for init.svc.bpfloader=stopped, still waiting...", delay);
+    }
+}
+
 Status BpfHandler::init(const char* cg2_path) {
-    // Make sure BPF programs are loaded before doing anything
-    android::bpf::waitForProgsLoaded();
+    if (base::GetProperty("bpf.progs_loaded", "") != "1") {
+        // Make sure BPF programs are loaded before doing anything
+        ALOGI("Waiting for BPF programs");
+
+        if (true || !modules::sdklevel::IsAtLeastV()) {
+            waitForNetProgsLoaded();
+            ALOGI("Networking BPF programs are loaded");
+
+            if (!base::SetProperty("ctl.start", "mdnsd_loadbpf")) {
+                ALOGE("Failed to set property ctl.start=mdnsd_loadbpf, see dmesg for reason.");
+                abort();
+            }
+
+            ALOGI("Waiting for remaining BPF programs");
+        }
+
+        android::bpf::waitForProgsLoaded();
+    }
     ALOGI("BPF programs are loaded");
 
     RETURN_IF_NOT_OK(initPrograms(cg2_path));
diff --git a/service-t/Android.bp b/service-t/Android.bp
index ef76eae..012c076 100644
--- a/service-t/Android.bp
+++ b/service-t/Android.bp
@@ -75,6 +75,7 @@
         "com.android.tethering",
     ],
     visibility: [
+        "//frameworks/base/services/tests/VpnTests",
         "//frameworks/base/tests/vcn",
         "//packages/modules/Connectivity/service",
         "//packages/modules/Connectivity/tests:__subpackages__",
diff --git a/service-t/src/com/android/server/NsdService.java b/service-t/src/com/android/server/NsdService.java
index 9ba49d2..46c435f 100644
--- a/service-t/src/com/android/server/NsdService.java
+++ b/service-t/src/com/android/server/NsdService.java
@@ -28,6 +28,7 @@
 import static android.net.nsd.NsdManager.RESOLVE_SERVICE_SUCCEEDED;
 import static android.net.nsd.NsdManager.SUBTYPE_LABEL_REGEX;
 import static android.net.nsd.NsdManager.TYPE_REGEX;
+import static android.os.Process.SYSTEM_UID;
 import static android.provider.DeviceConfig.NAMESPACE_TETHERING;
 
 import static com.android.modules.utils.build.SdkLevel.isAtLeastU;
@@ -92,6 +93,7 @@
 import com.android.modules.utils.build.SdkLevel;
 import com.android.net.module.util.CollectionUtils;
 import com.android.net.module.util.DeviceConfigUtils;
+import com.android.net.module.util.HandlerUtils;
 import com.android.net.module.util.InetAddressUtils;
 import com.android.net.module.util.PermissionUtils;
 import com.android.net.module.util.SharedLog;
@@ -115,6 +117,7 @@
 import java.net.NetworkInterface;
 import java.net.SocketException;
 import java.net.UnknownHostException;
+import java.time.Duration;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
@@ -738,6 +741,33 @@
                 return new ArraySet<>(subtypeMap.values());
             }
 
+            private boolean checkTtl(
+                        @Nullable Duration ttl, @NonNull ClientInfo clientInfo) {
+                if (ttl == null) {
+                    return true;
+                }
+
+                final long ttlSeconds = ttl.toSeconds();
+                final int uid = clientInfo.getUid();
+
+                // Allows Thread module in the system_server to register TTL that is smaller than
+                // 30 seconds
+                final long minTtlSeconds = uid == SYSTEM_UID ? 0 : NsdManager.TTL_SECONDS_MIN;
+
+                // Allows Thread module in the system_server to register TTL that is larger than
+                // 10 hours
+                final long maxTtlSeconds =
+                        uid == SYSTEM_UID ? 0xffffffffL : NsdManager.TTL_SECONDS_MAX;
+
+                if (ttlSeconds < minTtlSeconds || ttlSeconds > maxTtlSeconds) {
+                    mServiceLogs.e("ttlSeconds exceeds allowed range (value = "
+                            + ttlSeconds + ", allowedRange = [" + minTtlSeconds
+                            + ", " + maxTtlSeconds + " ])");
+                    return false;
+                }
+                return true;
+            }
+
             @Override
             public boolean processMessage(Message msg) {
                 final ClientInfo clientInfo;
@@ -964,11 +994,19 @@
                                 break;
                             }
 
+                            if (!checkTtl(advertisingRequest.getTtl(), clientInfo)) {
+                                clientInfo.onRegisterServiceFailedImmediately(clientRequestId,
+                                        NsdManager.FAILURE_BAD_PARAMETERS, false /* isLegacy */);
+                                break;
+                            }
+
                             serviceInfo.setSubtypes(subtypes);
                             maybeStartMonitoringSockets();
                             final MdnsAdvertisingOptions mdnsAdvertisingOptions =
-                                    MdnsAdvertisingOptions.newBuilder().setIsOnlyUpdate(
-                                            isUpdateOnly).build();
+                                    MdnsAdvertisingOptions.newBuilder()
+                                            .setIsOnlyUpdate(isUpdateOnly)
+                                            .setTtl(advertisingRequest.getTtl())
+                                            .build();
                             mAdvertiser.addOrUpdateService(transactionId, serviceInfo,
                                     mdnsAdvertisingOptions, clientInfo.mUid);
                             storeAdvertiserRequestMap(clientRequestId, transactionId, clientInfo,
@@ -1067,8 +1105,11 @@
                             maybeStartMonitoringSockets();
                             final MdnsListener listener = new ResolutionListener(clientRequestId,
                                     transactionId, resolveServiceType);
+                            final int ifaceIdx = info.getNetwork() != null
+                                    ? 0 : info.getInterfaceIndex();
                             final MdnsSearchOptions options = MdnsSearchOptions.newBuilder()
                                     .setNetwork(info.getNetwork())
+                                    .setInterfaceIndex(ifaceIdx)
                                     .setQueryMode(mMdnsFeatureFlags.isAggressiveQueryModeEnabled()
                                             ? AGGRESSIVE_QUERY_MODE
                                             : PASSIVE_QUERY_MODE)
@@ -1167,8 +1208,11 @@
                         maybeStartMonitoringSockets();
                         final MdnsListener listener = new ServiceInfoListener(clientRequestId,
                                 transactionId, resolveServiceType);
+                        final int ifIndex = info.getNetwork() != null
+                                ? 0 : info.getInterfaceIndex();
                         final MdnsSearchOptions options = MdnsSearchOptions.newBuilder()
                                 .setNetwork(info.getNetwork())
+                                .setInterfaceIndex(ifIndex)
                                 .setQueryMode(mMdnsFeatureFlags.isAggressiveQueryModeEnabled()
                                         ? AGGRESSIVE_QUERY_MODE
                                         : PASSIVE_QUERY_MODE)
@@ -1511,6 +1555,7 @@
                         network == null ? INetd.LOCAL_NET_ID : network.netId,
                         serviceInfo.getInterfaceIndex());
                 servInfo.setSubtypes(dedupSubtypeLabels(serviceInfo.getSubtypes()));
+                servInfo.setExpirationTime(serviceInfo.getExpirationTime());
                 return servInfo;
             }
 
@@ -1812,6 +1857,8 @@
                         mContext, MdnsFeatureFlags.NSD_UNICAST_REPLY_ENABLED))
                 .setIsAggressiveQueryModeEnabled(mDeps.isFeatureEnabled(
                         mContext, MdnsFeatureFlags.NSD_AGGRESSIVE_QUERY_MODE))
+                .setIsQueryWithKnownAnswerEnabled(mDeps.isFeatureEnabled(
+                        mContext, MdnsFeatureFlags.NSD_QUERY_WITH_KNOWN_ANSWER))
                 .setOverrideProvider(flag -> mDeps.isFeatureEnabled(
                         mContext, FORCE_ENABLE_FLAG_FOR_TEST_PREFIX + flag))
                 .build();
@@ -2510,6 +2557,14 @@
         pw.increaseIndent();
         mServiceLogs.reverseDump(pw);
         pw.decreaseIndent();
+
+        //Dump DiscoveryManager
+        pw.println();
+        pw.println("DiscoveryManager:");
+        pw.increaseIndent();
+        HandlerUtils.runWithScissorsForDump(
+                mNsdStateMachine.getHandler(), () -> mMdnsDiscoveryManager.dump(pw), 10_000);
+        pw.decreaseIndent();
     }
 
     private abstract static class ClientRequest {
@@ -2671,6 +2726,10 @@
             return sb.toString();
         }
 
+        public int getUid() {
+            return mUid;
+        }
+
         private boolean isPreSClient() {
             return mIsPreSClient;
         }
diff --git a/service-t/src/com/android/server/connectivity/mdns/EnqueueMdnsQueryCallable.java b/service-t/src/com/android/server/connectivity/mdns/EnqueueMdnsQueryCallable.java
index c4d3338..54943c7 100644
--- a/service-t/src/com/android/server/connectivity/mdns/EnqueueMdnsQueryCallable.java
+++ b/service-t/src/com/android/server/connectivity/mdns/EnqueueMdnsQueryCallable.java
@@ -23,6 +23,7 @@
 import android.text.TextUtils;
 import android.util.Pair;
 
+import com.android.net.module.util.CollectionUtils;
 import com.android.net.module.util.SharedLog;
 import com.android.server.connectivity.mdns.util.MdnsUtils;
 
@@ -63,8 +64,6 @@
     @NonNull
     private final WeakReference<MdnsSocketClientBase> weakRequestSender;
     @NonNull
-    private final MdnsPacketWriter packetWriter;
-    @NonNull
     private final String[] serviceTypeLabels;
     @NonNull
     private final List<String> subtypes;
@@ -79,11 +78,16 @@
     private final MdnsUtils.Clock clock;
     @NonNull
     private final SharedLog sharedLog;
+    @NonNull
+    private final MdnsServiceTypeClient.Dependencies dependencies;
     private final boolean onlyUseIpv6OnIpv6OnlyNetworks;
+    private final byte[] packetCreationBuffer = new byte[1500]; // TODO: use interface MTU
+    @NonNull
+    private final List<MdnsResponse> existingServices;
+    private final boolean isQueryWithKnownAnswer;
 
     EnqueueMdnsQueryCallable(
             @NonNull MdnsSocketClientBase requestSender,
-            @NonNull MdnsPacketWriter packetWriter,
             @NonNull String serviceType,
             @NonNull Collection<String> subtypes,
             boolean expectUnicastResponse,
@@ -93,9 +97,11 @@
             boolean sendDiscoveryQueries,
             @NonNull Collection<MdnsResponse> servicesToResolve,
             @NonNull MdnsUtils.Clock clock,
-            @NonNull SharedLog sharedLog) {
+            @NonNull SharedLog sharedLog,
+            @NonNull MdnsServiceTypeClient.Dependencies dependencies,
+            @NonNull Collection<MdnsResponse> existingServices,
+            boolean isQueryWithKnownAnswer) {
         weakRequestSender = new WeakReference<>(requestSender);
-        this.packetWriter = packetWriter;
         serviceTypeLabels = TextUtils.split(serviceType, "\\.");
         this.subtypes = new ArrayList<>(subtypes);
         this.expectUnicastResponse = expectUnicastResponse;
@@ -106,6 +112,9 @@
         this.servicesToResolve = new ArrayList<>(servicesToResolve);
         this.clock = clock;
         this.sharedLog = sharedLog;
+        this.dependencies = dependencies;
+        this.existingServices = new ArrayList<>(existingServices);
+        this.isQueryWithKnownAnswer = isQueryWithKnownAnswer;
     }
 
     /**
@@ -176,62 +185,86 @@
                 return Pair.create(INVALID_TRANSACTION_ID, new ArrayList<>());
             }
 
+            // Put the existing ptr records into known-answer section.
+            final List<MdnsRecord> knownAnswers = new ArrayList<>();
+            if (sendDiscoveryQueries) {
+                for (MdnsResponse existingService : existingServices) {
+                    for (MdnsPointerRecord ptrRecord : existingService.getPointerRecords()) {
+                        // Ignore any PTR records that don't match the current query.
+                        if (!CollectionUtils.any(questions,
+                                q -> q instanceof MdnsPointerRecord
+                                        && MdnsUtils.equalsDnsLabelIgnoreDnsCase(
+                                                q.getName(), ptrRecord.getName()))) {
+                            continue;
+                        }
+
+                        knownAnswers.add(new MdnsPointerRecord(
+                                ptrRecord.getName(),
+                                ptrRecord.getReceiptTime(),
+                                ptrRecord.getCacheFlush(),
+                                ptrRecord.getRemainingTTL(now), // Put the remaining ttl.
+                                ptrRecord.getPointer()));
+                    }
+                }
+            }
+
             final MdnsPacket queryPacket = new MdnsPacket(
                     transactionId,
                     MdnsConstants.FLAGS_QUERY,
                     questions,
-                    Collections.emptyList(), /* answers */
+                    knownAnswers,
                     Collections.emptyList(), /* authorityRecords */
                     Collections.emptyList() /* additionalRecords */);
-            MdnsUtils.writeMdnsPacket(packetWriter, queryPacket);
-            sendPacketToIpv4AndIpv6(requestSender, MdnsConstants.MDNS_PORT);
+            sendPacketToIpv4AndIpv6(requestSender, MdnsConstants.MDNS_PORT, queryPacket);
             for (Integer emulatorPort : castShellEmulatorMdnsPorts) {
-                sendPacketToIpv4AndIpv6(requestSender, emulatorPort);
+                sendPacketToIpv4AndIpv6(requestSender, emulatorPort, queryPacket);
             }
             return Pair.create(transactionId, subtypes);
-        } catch (IOException e) {
+        } catch (Exception e) {
             sharedLog.e(String.format("Failed to create mDNS packet for subtype: %s.",
                     TextUtils.join(",", subtypes)), e);
             return Pair.create(INVALID_TRANSACTION_ID, new ArrayList<>());
         }
     }
 
-    private void sendPacket(MdnsSocketClientBase requestSender, InetSocketAddress address)
-            throws IOException {
-        DatagramPacket packet = packetWriter.getPacket(address);
+    private void sendPacket(MdnsSocketClientBase requestSender, InetSocketAddress address,
+            MdnsPacket mdnsPacket) throws IOException {
+        final List<DatagramPacket> packets = dependencies.getDatagramPacketsFromMdnsPacket(
+                packetCreationBuffer, mdnsPacket, address, isQueryWithKnownAnswer);
         if (expectUnicastResponse) {
             // MdnsMultinetworkSocketClient is only available on T+
             if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
                     && requestSender instanceof MdnsMultinetworkSocketClient) {
                 ((MdnsMultinetworkSocketClient) requestSender).sendPacketRequestingUnicastResponse(
-                        packet, socketKey, onlyUseIpv6OnIpv6OnlyNetworks);
+                        packets, socketKey, onlyUseIpv6OnIpv6OnlyNetworks);
             } else {
                 requestSender.sendPacketRequestingUnicastResponse(
-                        packet, onlyUseIpv6OnIpv6OnlyNetworks);
+                        packets, onlyUseIpv6OnIpv6OnlyNetworks);
             }
         } else {
             if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
                     && requestSender instanceof MdnsMultinetworkSocketClient) {
                 ((MdnsMultinetworkSocketClient) requestSender)
                         .sendPacketRequestingMulticastResponse(
-                                packet, socketKey, onlyUseIpv6OnIpv6OnlyNetworks);
+                                packets, socketKey, onlyUseIpv6OnIpv6OnlyNetworks);
             } else {
                 requestSender.sendPacketRequestingMulticastResponse(
-                        packet, onlyUseIpv6OnIpv6OnlyNetworks);
+                        packets, onlyUseIpv6OnIpv6OnlyNetworks);
             }
         }
     }
 
-    private void sendPacketToIpv4AndIpv6(MdnsSocketClientBase requestSender, int port) {
+    private void sendPacketToIpv4AndIpv6(MdnsSocketClientBase requestSender, int port,
+            MdnsPacket mdnsPacket) {
         try {
             sendPacket(requestSender,
-                    new InetSocketAddress(MdnsConstants.getMdnsIPv4Address(), port));
+                    new InetSocketAddress(MdnsConstants.getMdnsIPv4Address(), port), mdnsPacket);
         } catch (IOException e) {
             sharedLog.e("Can't send packet to IPv4", e);
         }
         try {
             sendPacket(requestSender,
-                    new InetSocketAddress(MdnsConstants.getMdnsIPv6Address(), port));
+                    new InetSocketAddress(MdnsConstants.getMdnsIPv6Address(), port), mdnsPacket);
         } catch (IOException e) {
             sharedLog.e("Can't send packet to IPv6", e);
         }
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsAdvertiser.java b/service-t/src/com/android/server/connectivity/mdns/MdnsAdvertiser.java
index 0b60572..98c2d86 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsAdvertiser.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsAdvertiser.java
@@ -241,13 +241,10 @@
         }
 
         @Override
-        public void onDestroyed(@NonNull MdnsInterfaceSocket socket) {
-            for (int i = mAdvertiserRequests.size() - 1; i >= 0; i--) {
-                if (mAdvertiserRequests.valueAt(i).onAdvertiserDestroyed(socket)) {
-                    mAdvertiserRequests.removeAt(i);
-                }
-            }
-            mAllAdvertisers.remove(socket);
+        public void onAllServicesRemoved(@NonNull MdnsInterfaceSocket socket) {
+            if (DBG) { mSharedLog.i("onAllServicesRemoved: " + socket); }
+            // Try destroying the advertiser if all services has been removed
+            destroyAdvertiser(socket, false /* interfaceDestroyed */);
         }
     };
 
@@ -318,6 +315,30 @@
     }
 
     /**
+     * Destroys the advertiser for the interface indicated by {@code socket}.
+     *
+     * {@code interfaceDestroyed} should be set to {@code true} if this method is called because
+     * the associated interface has been destroyed.
+     */
+    private void destroyAdvertiser(MdnsInterfaceSocket socket, boolean interfaceDestroyed) {
+        InterfaceAdvertiserRequest advertiserRequest;
+
+        MdnsInterfaceAdvertiser advertiser = mAllAdvertisers.remove(socket);
+        if (advertiser != null) {
+            advertiser.destroyNow();
+            if (DBG) { mSharedLog.i("MdnsInterfaceAdvertiser is destroyed: " + advertiser); }
+        }
+
+        for (int i = mAdvertiserRequests.size() - 1; i >= 0; i--) {
+            advertiserRequest = mAdvertiserRequests.valueAt(i);
+            if (advertiserRequest.onAdvertiserDestroyed(socket, interfaceDestroyed)) {
+                if (DBG) { mSharedLog.i("AdvertiserRequest is removed: " + advertiserRequest); }
+                mAdvertiserRequests.removeAt(i);
+            }
+        }
+    }
+
+    /**
      * A request for a {@link MdnsInterfaceAdvertiser}.
      *
      * This class tracks services to be advertised on all sockets provided via a registered
@@ -336,13 +357,22 @@
         }
 
         /**
-         * Called when an advertiser was destroyed, after all services were unregistered and it sent
-         * exit announcements, or the interface is gone.
+         * Called when the interface advertiser associated with {@code socket} has been destroyed.
          *
-         * @return true if this {@link InterfaceAdvertiserRequest} should now be deleted.
+         * {@code interfaceDestroyed} should be set to {@code true} if this method is called because
+         * the associated interface has been destroyed.
+         *
+         * @return true if the {@link InterfaceAdvertiserRequest} should now be deleted
          */
-        boolean onAdvertiserDestroyed(@NonNull MdnsInterfaceSocket socket) {
+        boolean onAdvertiserDestroyed(
+                @NonNull MdnsInterfaceSocket socket, boolean interfaceDestroyed) {
             final MdnsInterfaceAdvertiser removedAdvertiser = mAdvertisers.remove(socket);
+            if (removedAdvertiser != null
+                    && !interfaceDestroyed && mPendingRegistrations.size() > 0) {
+                mSharedLog.wtf(
+                        "unexpected onAdvertiserDestroyed() when there are pending registrations");
+            }
+
             if (mMdnsFeatureFlags.mIsMdnsOffloadFeatureEnabled && removedAdvertiser != null) {
                 final String interfaceName = removedAdvertiser.getSocketInterfaceName();
                 // If the interface is destroyed, stop all hardware offloading on that
@@ -449,7 +479,8 @@
             mPendingRegistrations.put(id, registration);
             for (int i = 0; i < mAdvertisers.size(); i++) {
                 try {
-                    mAdvertisers.valueAt(i).addService(id, registration.getServiceInfo());
+                    mAdvertisers.valueAt(i).addService(id, registration.getServiceInfo(),
+                            registration.getAdvertisingOptions());
                 } catch (NameConflictException e) {
                     mSharedLog.wtf("Name conflict adding services that should have unique names",
                             e);
@@ -515,7 +546,7 @@
                 final Registration registration = mPendingRegistrations.valueAt(i);
                 try {
                     advertiser.addService(mPendingRegistrations.keyAt(i),
-                            registration.getServiceInfo());
+                            registration.getServiceInfo(), registration.getAdvertisingOptions());
                 } catch (NameConflictException e) {
                     mSharedLog.wtf("Name conflict adding services that should have unique names",
                             e);
@@ -527,7 +558,7 @@
         public void onInterfaceDestroyed(@NonNull SocketKey socketKey,
                 @NonNull MdnsInterfaceSocket socket) {
             final MdnsInterfaceAdvertiser advertiser = mAdvertisers.get(socket);
-            if (advertiser != null) advertiser.destroyNow();
+            if (advertiser != null) destroyAdvertiser(socket, true /* interfaceDestroyed */);
         }
 
         @Override
@@ -587,15 +618,17 @@
         @NonNull
         private NsdServiceInfo mServiceInfo;
         final int mClientUid;
+        private final MdnsAdvertisingOptions mAdvertisingOptions;
         int mConflictDuringProbingCount;
         int mConflictAfterProbingCount;
 
-
-        private Registration(@NonNull NsdServiceInfo serviceInfo, int clientUid) {
+        private Registration(@NonNull NsdServiceInfo serviceInfo, int clientUid,
+                @NonNull MdnsAdvertisingOptions advertisingOptions) {
             this.mOriginalServiceName = serviceInfo.getServiceName();
             this.mOriginalHostname = serviceInfo.getHostname();
             this.mServiceInfo = serviceInfo;
             this.mClientUid = clientUid;
+            this.mAdvertisingOptions = advertisingOptions;
         }
 
         /** Check if the new {@link NsdServiceInfo} doesn't update any data other than subtypes. */
@@ -697,6 +730,11 @@
         public NsdServiceInfo getServiceInfo() {
             return mServiceInfo;
         }
+
+        @NonNull
+        public MdnsAdvertisingOptions getAdvertisingOptions() {
+            return mAdvertisingOptions;
+        }
     }
 
     /**
@@ -855,7 +893,7 @@
             }
             mSharedLog.i("Adding service " + service + " with ID " + id + " and subtypes "
                     + subtypes + " advertisingOptions " + advertisingOptions);
-            registration = new Registration(service, clientUid);
+            registration = new Registration(service, clientUid, advertisingOptions);
             final BiPredicate<Network, InterfaceAdvertiserRequest> checkConflictFilter;
             if (network == null) {
                 // If registering on all networks, no advertiser must have conflicts
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsAdvertisingOptions.java b/service-t/src/com/android/server/connectivity/mdns/MdnsAdvertisingOptions.java
index e7a6ca7..a81d1e4 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsAdvertisingOptions.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsAdvertisingOptions.java
@@ -16,6 +16,11 @@
 
 package com.android.server.connectivity.mdns;
 
+import android.annotation.Nullable;
+
+import java.time.Duration;
+import java.util.Objects;
+
 /**
  * API configuration parameters for advertising the mDNS service.
  *
@@ -27,13 +32,15 @@
 
     private static MdnsAdvertisingOptions sDefaultOptions;
     private final boolean mIsOnlyUpdate;
+    @Nullable
+    private final Duration mTtl;
 
     /**
      * Parcelable constructs for a {@link MdnsAdvertisingOptions}.
      */
-    MdnsAdvertisingOptions(
-            boolean isOnlyUpdate) {
+    MdnsAdvertisingOptions(boolean isOnlyUpdate, @Nullable Duration ttl) {
         this.mIsOnlyUpdate = isOnlyUpdate;
+        this.mTtl = ttl;
     }
 
     /**
@@ -60,9 +67,36 @@
         return mIsOnlyUpdate;
     }
 
+    /**
+     * Returns the TTL for all records in a service.
+     */
+    @Nullable
+    public Duration getTtl() {
+        return mTtl;
+    }
+
+    @Override
+    public boolean equals(Object other) {
+        if (this == other) {
+            return true;
+        } else if (!(other instanceof MdnsAdvertisingOptions)) {
+            return false;
+        } else {
+            final MdnsAdvertisingOptions otherOptions = (MdnsAdvertisingOptions) other;
+            return mIsOnlyUpdate == otherOptions.mIsOnlyUpdate
+                    && Objects.equals(mTtl, otherOptions.mTtl);
+        }
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(mIsOnlyUpdate, mTtl);
+    }
+
     @Override
     public String toString() {
-        return "MdnsAdvertisingOptions{" + "mIsOnlyUpdate=" + mIsOnlyUpdate + '}';
+        return "MdnsAdvertisingOptions{" + "mIsOnlyUpdate=" + mIsOnlyUpdate + ", mTtl=" + mTtl
+                + '}';
     }
 
     /**
@@ -70,6 +104,8 @@
      */
     public static final class Builder {
         private boolean mIsOnlyUpdate = false;
+        @Nullable
+        private Duration mTtl;
 
         private Builder() {
         }
@@ -83,10 +119,18 @@
         }
 
         /**
+         * Sets the TTL duration for all records of the service.
+         */
+        public Builder setTtl(@Nullable Duration ttl) {
+            this.mTtl = ttl;
+            return this;
+        }
+
+        /**
          * Builds a {@link MdnsAdvertisingOptions} with the arguments supplied to this builder.
          */
         public MdnsAdvertisingOptions build() {
-            return new MdnsAdvertisingOptions(mIsOnlyUpdate);
+            return new MdnsAdvertisingOptions(mIsOnlyUpdate, mTtl);
         }
     }
 }
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsDiscoveryManager.java b/service-t/src/com/android/server/connectivity/mdns/MdnsDiscoveryManager.java
index 1d6039c..0ab7a76 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsDiscoveryManager.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsDiscoveryManager.java
@@ -34,6 +34,7 @@
 import com.android.server.connectivity.mdns.util.MdnsUtils;
 
 import java.io.IOException;
+import java.io.PrintWriter;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Objects;
@@ -240,11 +241,30 @@
             }
         }
         // Request the network for discovery.
+        // This requests sockets on all networks even if the searchOptions have a given interface
+        // index (with getNetwork==null, for local interfaces), and only uses matching interfaces
+        // in that case. While this is a simple solution to only use matching sockets, a better
+        // practice would be to only request the correct socket for discovery.
+        // TODO: avoid requesting extra sockets after migrating P2P and tethering networks to local
+        // NetworkAgents.
         socketClient.notifyNetworkRequested(listener, searchOptions.getNetwork(),
                 new MdnsSocketClientBase.SocketCreationCallback() {
                     @Override
                     public void onSocketCreated(@NonNull SocketKey socketKey) {
                         discoveryExecutor.ensureRunningOnHandlerThread();
+                        final int searchInterfaceIndex = searchOptions.getInterfaceIndex();
+                        if (searchOptions.getNetwork() == null
+                                && searchInterfaceIndex > 0
+                                // The interface index in options should only match interfaces that
+                                // do not have any Network; a matching Network should be provided
+                                // otherwise.
+                                && (socketKey.getNetwork() != null
+                                    || socketKey.getInterfaceIndex() != searchInterfaceIndex)) {
+                            sharedLog.i("Skipping " + socketKey + " as ifIndex "
+                                    + searchInterfaceIndex + " was requested.");
+                            return;
+                        }
+
                         // All listeners of the same service types shares the same
                         // MdnsServiceTypeClient.
                         MdnsServiceTypeClient serviceTypeClient =
@@ -361,6 +381,20 @@
         return new MdnsServiceTypeClient(
                 serviceType, socketClient,
                 executorProvider.newServiceTypeClientSchedulerExecutor(), socketKey,
-                sharedLog.forSubComponent(tag), looper, serviceCache);
+                sharedLog.forSubComponent(tag), looper, serviceCache, mdnsFeatureFlags);
+    }
+
+    /**
+     * Dump DiscoveryManager state.
+     */
+    public void dump(PrintWriter pw) {
+        discoveryExecutor.checkAndRunOnHandlerThread(() -> {
+            pw.println();
+            // Dump ServiceTypeClients
+            for (MdnsServiceTypeClient serviceTypeClient
+                    : perSocketServiceTypeClients.getAllMdnsServiceTypeClient()) {
+                serviceTypeClient.dump(pw);
+            }
+        });
     }
 }
\ No newline at end of file
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 56202fd..f4a08ba 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsFeatureFlags.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsFeatureFlags.java
@@ -62,6 +62,11 @@
      */
     public static final String NSD_AGGRESSIVE_QUERY_MODE = "nsd_aggressive_query_mode";
 
+    /**
+     * A feature flag to control whether the query with known-answer should be enabled.
+     */
+    public static final String NSD_QUERY_WITH_KNOWN_ANSWER = "nsd_query_with_known_answer";
+
     // Flag for offload feature
     public final boolean mIsMdnsOffloadFeatureEnabled;
 
@@ -83,6 +88,9 @@
     // Flag for aggressive query mode
     public final boolean mIsAggressiveQueryModeEnabled;
 
+    // Flag for query with known-answer
+    public final boolean mIsQueryWithKnownAnswerEnabled;
+
     @Nullable
     private final FlagOverrideProvider mOverrideProvider;
 
@@ -126,6 +134,14 @@
     }
 
     /**
+     * Indicates whether {@link #NSD_QUERY_WITH_KNOWN_ANSWER} is enabled, including for testing.
+     */
+    public boolean isQueryWithKnownAnswerEnabled() {
+        return mIsQueryWithKnownAnswerEnabled
+                || isForceEnabledForTest(NSD_QUERY_WITH_KNOWN_ANSWER);
+    }
+
+    /**
      * The constructor for {@link MdnsFeatureFlags}.
      */
     public MdnsFeatureFlags(boolean isOffloadFeatureEnabled,
@@ -135,6 +151,7 @@
             boolean isKnownAnswerSuppressionEnabled,
             boolean isUnicastReplyEnabled,
             boolean isAggressiveQueryModeEnabled,
+            boolean isQueryWithKnownAnswerEnabled,
             @Nullable FlagOverrideProvider overrideProvider) {
         mIsMdnsOffloadFeatureEnabled = isOffloadFeatureEnabled;
         mIncludeInetAddressRecordsInProbing = includeInetAddressRecordsInProbing;
@@ -143,6 +160,7 @@
         mIsKnownAnswerSuppressionEnabled = isKnownAnswerSuppressionEnabled;
         mIsUnicastReplyEnabled = isUnicastReplyEnabled;
         mIsAggressiveQueryModeEnabled = isAggressiveQueryModeEnabled;
+        mIsQueryWithKnownAnswerEnabled = isQueryWithKnownAnswerEnabled;
         mOverrideProvider = overrideProvider;
     }
 
@@ -162,6 +180,7 @@
         private boolean mIsKnownAnswerSuppressionEnabled;
         private boolean mIsUnicastReplyEnabled;
         private boolean mIsAggressiveQueryModeEnabled;
+        private boolean mIsQueryWithKnownAnswerEnabled;
         private FlagOverrideProvider mOverrideProvider;
 
         /**
@@ -175,6 +194,7 @@
             mIsKnownAnswerSuppressionEnabled = false;
             mIsUnicastReplyEnabled = true;
             mIsAggressiveQueryModeEnabled = false;
+            mIsQueryWithKnownAnswerEnabled = false;
             mOverrideProvider = null;
         }
 
@@ -261,6 +281,16 @@
         }
 
         /**
+         * Set whether the query with known-answer is enabled.
+         *
+         * @see #NSD_QUERY_WITH_KNOWN_ANSWER
+         */
+        public Builder setIsQueryWithKnownAnswerEnabled(boolean isQueryWithKnownAnswerEnabled) {
+            mIsQueryWithKnownAnswerEnabled = isQueryWithKnownAnswerEnabled;
+            return this;
+        }
+
+        /**
          * Builds a {@link MdnsFeatureFlags} with the arguments supplied to this builder.
          */
         public MdnsFeatureFlags build() {
@@ -271,6 +301,7 @@
                     mIsKnownAnswerSuppressionEnabled,
                     mIsUnicastReplyEnabled,
                     mIsAggressiveQueryModeEnabled,
+                    mIsQueryWithKnownAnswerEnabled,
                     mOverrideProvider);
         }
     }
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsInterfaceAdvertiser.java b/service-t/src/com/android/server/connectivity/mdns/MdnsInterfaceAdvertiser.java
index aa51c41..0b2003f 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsInterfaceAdvertiser.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsInterfaceAdvertiser.java
@@ -22,12 +22,10 @@
 import android.annotation.Nullable;
 import android.annotation.RequiresApi;
 import android.net.LinkAddress;
-import android.net.nsd.NsdManager;
 import android.net.nsd.NsdServiceInfo;
 import android.os.Build;
 import android.os.Handler;
 import android.os.Looper;
-import android.util.ArraySet;
 
 import com.android.internal.annotations.VisibleForTesting;
 import com.android.net.module.util.HexDump;
@@ -38,6 +36,7 @@
 
 import java.io.IOException;
 import java.net.InetSocketAddress;
+import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -102,12 +101,15 @@
                 @NonNull MdnsInterfaceAdvertiser advertiser, int serviceId, int conflictType);
 
         /**
-         * Called by the advertiser when it destroyed itself.
+         * Called when all services on this interface advertiser has already been removed and exit
+         * announcements have been sent.
          *
-         * This can happen after a call to {@link #destroyNow()}, or after all services were
-         * unregistered and the advertiser finished sending exit announcements.
+         * <p>It's guaranteed that there are no service registrations in the
+         * MdnsInterfaceAdvertiser when this callback is invoked.
+         *
+         * <p>This is typically listened by the {@link MdnsAdvertiser} to release the resources
          */
-        void onDestroyed(@NonNull MdnsInterfaceSocket socket);
+        void onAllServicesRemoved(@NonNull MdnsInterfaceSocket socket);
     }
 
     /**
@@ -133,6 +135,15 @@
 
             mAnnouncer.startSending(info.getServiceId(), announcementInfo,
                     0L /* initialDelayMs */);
+
+            // Re-announce the services which have the same custom hostname.
+            final String hostname = mRecordRepository.getHostnameForServiceId(info.getServiceId());
+            if (hostname != null) {
+                final List<MdnsAnnouncer.AnnouncementInfo> announcementInfos =
+                        new ArrayList<>(mRecordRepository.restartAnnouncingForHostname(hostname));
+                announcementInfos.removeIf((i) -> i.getServiceId() == info.getServiceId());
+                reannounceServices(announcementInfos);
+            }
         }
     }
 
@@ -149,10 +160,11 @@
         public void onFinished(@NonNull BaseAnnouncementInfo info) {
             if (info instanceof MdnsAnnouncer.ExitAnnouncementInfo) {
                 mRecordRepository.removeService(info.getServiceId());
-
-                if (mRecordRepository.getServicesCount() == 0) {
-                    destroyNow();
-                }
+                mCbHandler.post(() -> {
+                    if (mRecordRepository.getServicesCount() == 0) {
+                        mCb.onAllServicesRemoved(mSocket);
+                    }
+                });
             }
         }
     }
@@ -234,8 +246,7 @@
      * Start the advertiser.
      *
      * The advertiser will stop itself when all services are removed and exit announcements sent,
-     * notifying via {@link Callback#onDestroyed}. This can also be triggered manually via
-     * {@link #destroyNow()}.
+     * notifying via {@link Callback#onAllServicesRemoved}.
      */
     public void start() {
         mSocket.addPacketHandler(this);
@@ -258,8 +269,10 @@
      *
      * @throws NameConflictException There is already a service being advertised with that name.
      */
-    public void addService(int id, NsdServiceInfo service) throws NameConflictException {
-        final int replacedExitingService = mRecordRepository.addService(id, service);
+    public void addService(int id, NsdServiceInfo service,
+            @NonNull MdnsAdvertisingOptions advertisingOptions) throws NameConflictException {
+        final int replacedExitingService =
+                mRecordRepository.addService(id, service, advertisingOptions.getTtl());
         // Cancel announcements for the existing service. This only happens for exiting services
         // (so cancelling exiting announcements), as per RecordRepository.addService.
         if (replacedExitingService >= 0) {
@@ -279,10 +292,11 @@
         if (!mRecordRepository.hasActiveService(id)) return;
         mProber.stop(id);
         mAnnouncer.stop(id);
+        final String hostname = mRecordRepository.getHostnameForServiceId(id);
         final MdnsAnnouncer.ExitAnnouncementInfo exitInfo = mRecordRepository.exitService(id);
         if (exitInfo != null) {
-            // This effectively schedules destroyNow(), as it is to be called when the exit
-            // announcement finishes if there is no service left.
+            // This effectively schedules onAllServicesRemoved(), as it is to be called when the
+            // exit announcement finishes if there is no service left.
             // A non-zero exit announcement delay follows legacy mdnsresponder behavior, and is
             // also useful to ensure that when a host receives the exit announcement, the service
             // has been unregistered on all interfaces; so an announcement sent from interface A
@@ -292,9 +306,22 @@
         } else {
             // No exit announcement necessary: remove the service immediately.
             mRecordRepository.removeService(id);
-            if (mRecordRepository.getServicesCount() == 0) {
-                destroyNow();
-            }
+            mCbHandler.post(() -> {
+                if (mRecordRepository.getServicesCount() == 0) {
+                    mCb.onAllServicesRemoved(mSocket);
+                }
+            });
+        }
+        // Re-probe/re-announce the services which have the same custom hostname. These services
+        // were probed/announced using host addresses which were just removed so they should be
+        // re-probed/re-announced without those addresses.
+        if (hostname != null) {
+            final List<MdnsProber.ProbingInfo> probingInfos =
+                    mRecordRepository.restartProbingForHostname(hostname);
+            reprobeServices(probingInfos);
+            final List<MdnsAnnouncer.AnnouncementInfo> announcementInfos =
+                    mRecordRepository.restartAnnouncingForHostname(hostname);
+            reannounceServices(announcementInfos);
         }
     }
 
@@ -328,7 +355,8 @@
     /**
      * Destroy the advertiser immediately, not sending any exit announcement.
      *
-     * <p>Useful when the underlying network went away. This will trigger an onDestroyed callback.
+     * <p>This is typically called when all services on the interface are removed or when the
+     * underlying network went away.
      */
     public void destroyNow() {
         for (int serviceId : mRecordRepository.clearServices()) {
@@ -337,7 +365,6 @@
         }
         mReplySender.cancelAll();
         mSocket.removePacketHandler(this);
-        mCbHandler.post(() -> mCb.onDestroyed(mSocket));
     }
 
     /**
@@ -440,4 +467,19 @@
             return new byte[0];
         }
     }
+
+    private void reprobeServices(List<MdnsProber.ProbingInfo> probingInfos) {
+        for (MdnsProber.ProbingInfo probingInfo : probingInfos) {
+            mProber.stop(probingInfo.getServiceId());
+            mProber.startProbing(probingInfo);
+        }
+    }
+
+    private void reannounceServices(List<MdnsAnnouncer.AnnouncementInfo> announcementInfos) {
+        for (MdnsAnnouncer.AnnouncementInfo announcementInfo : announcementInfos) {
+            mAnnouncer.stop(announcementInfo.getServiceId());
+            mAnnouncer.startSending(
+                    announcementInfo.getServiceId(), announcementInfo, 0 /* initialDelayMs */);
+        }
+    }
 }
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsMultinetworkSocketClient.java b/service-t/src/com/android/server/connectivity/mdns/MdnsMultinetworkSocketClient.java
index 869ac9b..fcfb15f 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsMultinetworkSocketClient.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsMultinetworkSocketClient.java
@@ -27,6 +27,7 @@
 import android.os.Handler;
 import android.os.Looper;
 import android.util.ArrayMap;
+import android.util.Log;
 
 import com.android.net.module.util.SharedLog;
 
@@ -213,24 +214,30 @@
         return true;
     }
 
-    private void sendMdnsPacket(@NonNull DatagramPacket packet, @NonNull SocketKey targetSocketKey,
-            boolean onlyUseIpv6OnIpv6OnlyNetworks) {
+    private void sendMdnsPackets(@NonNull List<DatagramPacket> packets,
+            @NonNull SocketKey targetSocketKey, boolean onlyUseIpv6OnIpv6OnlyNetworks) {
         final MdnsInterfaceSocket socket = getTargetSocket(targetSocketKey);
         if (socket == null) {
             mSharedLog.e("No socket matches targetSocketKey=" + targetSocketKey);
             return;
         }
+        if (packets.isEmpty()) {
+            Log.wtf(TAG, "No mDns packets to send");
+            return;
+        }
 
-        final boolean isIpv6 = ((InetSocketAddress) packet.getSocketAddress()).getAddress()
-                instanceof Inet6Address;
-        final boolean isIpv4 = ((InetSocketAddress) packet.getSocketAddress()).getAddress()
-                instanceof Inet4Address;
+        final boolean isIpv6 = ((InetSocketAddress) packets.get(0).getSocketAddress())
+                .getAddress() instanceof Inet6Address;
+        final boolean isIpv4 = ((InetSocketAddress) packets.get(0).getSocketAddress())
+                .getAddress() instanceof Inet4Address;
         final boolean shouldQueryIpv6 = !onlyUseIpv6OnIpv6OnlyNetworks || !socket.hasJoinedIpv4();
         // Check ip capability and network before sending packet
         if ((isIpv6 && socket.hasJoinedIpv6() && shouldQueryIpv6)
                 || (isIpv4 && socket.hasJoinedIpv4())) {
             try {
-                socket.send(packet);
+                for (DatagramPacket packet : packets) {
+                    socket.send(packet);
+                }
             } catch (IOException e) {
                 mSharedLog.e("Failed to send a mDNS packet.", e);
             }
@@ -259,34 +266,34 @@
     }
 
     /**
-     * Send a mDNS request packet via given socket key that asks for multicast response.
+     * Send mDNS request packets via given socket key that asks for multicast response.
      */
-    public void sendPacketRequestingMulticastResponse(@NonNull DatagramPacket packet,
+    public void sendPacketRequestingMulticastResponse(@NonNull List<DatagramPacket> packets,
             @NonNull SocketKey socketKey, boolean onlyUseIpv6OnIpv6OnlyNetworks) {
-        mHandler.post(() -> sendMdnsPacket(packet, socketKey, onlyUseIpv6OnIpv6OnlyNetworks));
+        mHandler.post(() -> sendMdnsPackets(packets, socketKey, onlyUseIpv6OnIpv6OnlyNetworks));
     }
 
     @Override
     public void sendPacketRequestingMulticastResponse(
-            @NonNull DatagramPacket packet, boolean onlyUseIpv6OnIpv6OnlyNetworks) {
+            @NonNull List<DatagramPacket> packets, boolean onlyUseIpv6OnIpv6OnlyNetworks) {
         throw new UnsupportedOperationException("This socket client need to specify the socket to"
                 + "send packet");
     }
 
     /**
-     * Send a mDNS request packet via given socket key that asks for unicast response.
+     * Send mDNS request packets via given socket key that asks for unicast response.
      *
      * <p>The socket client may use a null network to identify some or all interfaces, in which case
      * passing null sends the packet to these.
      */
-    public void sendPacketRequestingUnicastResponse(@NonNull DatagramPacket packet,
+    public void sendPacketRequestingUnicastResponse(@NonNull List<DatagramPacket> packets,
             @NonNull SocketKey socketKey, boolean onlyUseIpv6OnIpv6OnlyNetworks) {
-        mHandler.post(() -> sendMdnsPacket(packet, socketKey, onlyUseIpv6OnIpv6OnlyNetworks));
+        mHandler.post(() -> sendMdnsPackets(packets, socketKey, onlyUseIpv6OnIpv6OnlyNetworks));
     }
 
     @Override
     public void sendPacketRequestingUnicastResponse(
-            @NonNull DatagramPacket packet, boolean onlyUseIpv6OnIpv6OnlyNetworks) {
+            @NonNull List<DatagramPacket> packets, boolean onlyUseIpv6OnIpv6OnlyNetworks) {
         throw new UnsupportedOperationException("This socket client need to specify the socket to"
                 + "send packet");
     }
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsPacket.java b/service-t/src/com/android/server/connectivity/mdns/MdnsPacket.java
index 1fabd49..83ecabc 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsPacket.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsPacket.java
@@ -42,7 +42,7 @@
     @NonNull
     public final List<MdnsRecord> additionalRecords;
 
-    MdnsPacket(int flags,
+    public MdnsPacket(int flags,
             @NonNull List<MdnsRecord> questions,
             @NonNull List<MdnsRecord> answers,
             @NonNull List<MdnsRecord> authorityRecords,
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsRecord.java b/service-t/src/com/android/server/connectivity/mdns/MdnsRecord.java
index 4b43989..1f9f42b 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsRecord.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsRecord.java
@@ -23,7 +23,8 @@
 import android.os.SystemClock;
 import android.text.TextUtils;
 
-import com.android.internal.annotations.VisibleForTesting;
+import androidx.annotation.VisibleForTesting;
+
 import com.android.server.connectivity.mdns.util.MdnsUtils;
 
 import java.io.IOException;
@@ -231,7 +232,7 @@
      * @param writer The writer to use.
      * @param now    The current system time. This is used when writing the updated TTL.
      */
-    @VisibleForTesting
+    @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
     public final void write(MdnsPacketWriter writer, long now) throws IOException {
         writeHeaderFields(writer);
 
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsRecordRepository.java b/service-t/src/com/android/server/connectivity/mdns/MdnsRecordRepository.java
index ed0bde2..073e465 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsRecordRepository.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsRecordRepository.java
@@ -45,6 +45,7 @@
 import java.net.InetAddress;
 import java.net.InetSocketAddress;
 import java.net.NetworkInterface;
+import java.time.Duration;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
@@ -75,9 +76,9 @@
     // TTL for records with a host name as the resource record's name (e.g., A, AAAA, HINFO) or a
     // host name contained within the resource record's rdata (e.g., SRV, reverse mapping PTR
     // record)
-    private static final long NAME_RECORDS_TTL_MILLIS = TimeUnit.SECONDS.toMillis(120);
+    private static final long DEFAULT_NAME_RECORDS_TTL_MILLIS = TimeUnit.SECONDS.toMillis(120);
     // TTL for other records
-    private static final long NON_NAME_RECORDS_TTL_MILLIS = TimeUnit.MINUTES.toMillis(75);
+    private static final long DEFAULT_NON_NAME_RECORDS_TTL_MILLIS = TimeUnit.MINUTES.toMillis(75);
 
     // Top-level domain for link-local queries, as per RFC6762 3.
     private static final String LOCAL_TLD = "local";
@@ -193,6 +194,9 @@
          */
         private boolean isProbing;
 
+        @Nullable
+        private Duration ttl;
+
         /**
          * Create a ServiceRegistration with only update the subType.
          */
@@ -200,16 +204,32 @@
             NsdServiceInfo newServiceInfo = new NsdServiceInfo(serviceInfo);
             newServiceInfo.setSubtypes(newSubtypes);
             return new ServiceRegistration(srvRecord.record.getServiceHost(), newServiceInfo,
-                    repliedServiceCount, sentPacketCount, exiting, isProbing);
+                    repliedServiceCount, sentPacketCount, exiting, isProbing, ttl);
         }
 
         /**
          * Create a ServiceRegistration for dns-sd service registration (RFC6763).
          */
         ServiceRegistration(@NonNull String[] deviceHostname, @NonNull NsdServiceInfo serviceInfo,
-                int repliedServiceCount, int sentPacketCount, boolean exiting, boolean isProbing) {
+                int repliedServiceCount, int sentPacketCount, boolean exiting, boolean isProbing,
+                @Nullable Duration ttl) {
             this.serviceInfo = serviceInfo;
 
+            final long nonNameRecordsTtlMillis;
+            final long nameRecordsTtlMillis;
+
+            // When custom TTL is specified, all records of the service will use the custom TTL.
+            // This is typically useful for SRP (Service Registration Protocol:
+            // https://datatracker.ietf.org/doc/html/draft-ietf-dnssd-srp-24) Advertising Proxy
+            // where all records in a single SRP are required the same TTL.
+            if (ttl != null) {
+                nonNameRecordsTtlMillis = ttl.toMillis();
+                nameRecordsTtlMillis = ttl.toMillis();
+            } else {
+                nonNameRecordsTtlMillis = DEFAULT_NON_NAME_RECORDS_TTL_MILLIS;
+                nameRecordsTtlMillis = DEFAULT_NAME_RECORDS_TTL_MILLIS;
+            }
+
             final boolean hasService = !TextUtils.isEmpty(serviceInfo.getServiceType());
             final boolean hasCustomHost = !TextUtils.isEmpty(serviceInfo.getHostname());
             final String[] hostname =
@@ -229,7 +249,7 @@
                                 serviceType,
                                 0L /* receiptTimeMillis */,
                                 false /* cacheFlush */,
-                                NON_NAME_RECORDS_TTL_MILLIS,
+                                nonNameRecordsTtlMillis,
                                 serviceName),
                         true /* sharedName */));
                 for (String subtype : serviceInfo.getSubtypes()) {
@@ -239,7 +259,7 @@
                                     MdnsUtils.constructFullSubtype(serviceType, subtype),
                                     0L /* receiptTimeMillis */,
                                     false /* cacheFlush */,
-                                    NON_NAME_RECORDS_TTL_MILLIS,
+                                    nonNameRecordsTtlMillis,
                                     serviceName),
                             true /* sharedName */));
                 }
@@ -249,7 +269,7 @@
                         new MdnsServiceRecord(serviceName,
                                 0L /* receiptTimeMillis */,
                                 true /* cacheFlush */,
-                                NAME_RECORDS_TTL_MILLIS,
+                                nameRecordsTtlMillis,
                                 0 /* servicePriority */, 0 /* serviceWeight */,
                                 serviceInfo.getPort(),
                                 hostname),
@@ -261,7 +281,7 @@
                                 0L /* receiptTimeMillis */,
                                 // Service name is verified unique after probing
                                 true /* cacheFlush */,
-                                NON_NAME_RECORDS_TTL_MILLIS,
+                                nonNameRecordsTtlMillis,
                                 attrsToTextEntries(serviceInfo.getAttributes())),
                         false /* sharedName */);
 
@@ -275,7 +295,7 @@
                                 DNS_SD_SERVICE_TYPE,
                                 0L /* receiptTimeMillis */,
                                 false /* cacheFlush */,
-                                NON_NAME_RECORDS_TTL_MILLIS,
+                                nonNameRecordsTtlMillis,
                                 serviceType),
                         true /* sharedName */));
             } else {
@@ -292,7 +312,7 @@
                                     new MdnsInetAddressRecord(hostname,
                                             0L /* receiptTimeMillis */,
                                             true /* cacheFlush */,
-                                            NAME_RECORDS_TTL_MILLIS,
+                                            nameRecordsTtlMillis,
                                             address),
                                     false /* sharedName */));
                 }
@@ -315,9 +335,9 @@
          * @param serviceInfo Service to advertise
          */
         ServiceRegistration(@NonNull String[] deviceHostname, @NonNull NsdServiceInfo serviceInfo,
-                int repliedServiceCount, int sentPacketCount) {
+                int repliedServiceCount, int sentPacketCount, @Nullable Duration ttl) {
             this(deviceHostname, serviceInfo,repliedServiceCount, sentPacketCount,
-                    false /* exiting */, true /* isProbing */);
+                    false /* exiting */, true /* isProbing */, ttl);
         }
 
         void setProbing(boolean probing) {
@@ -339,7 +359,7 @@
                             revDnsAddr,
                             0L /* receiptTimeMillis */,
                             true /* cacheFlush */,
-                            NAME_RECORDS_TTL_MILLIS,
+                            DEFAULT_NAME_RECORDS_TTL_MILLIS,
                             mDeviceHostname),
                     false /* sharedName */));
 
@@ -349,7 +369,7 @@
                             mDeviceHostname,
                             0L /* receiptTimeMillis */,
                             true /* cacheFlush */,
-                            NAME_RECORDS_TTL_MILLIS,
+                            DEFAULT_NAME_RECORDS_TTL_MILLIS,
                             addr.getAddress()),
                     false /* sharedName */));
         }
@@ -378,11 +398,13 @@
      * This may remove/replace any existing service that used the name added but is exiting.
      * @param serviceId A unique service ID.
      * @param serviceInfo Service info to add.
+     * @param ttl the TTL duration for all records of {@code serviceInfo} or {@code null}
      * @return If the added service replaced another with a matching name (which was exiting), the
      *         ID of the replaced service.
      * @throws NameConflictException There is already a (non-exiting) service using the name.
      */
-    public int addService(int serviceId, NsdServiceInfo serviceInfo) throws NameConflictException {
+    public int addService(int serviceId, NsdServiceInfo serviceInfo, @Nullable Duration ttl)
+            throws NameConflictException {
         if (mServices.contains(serviceId)) {
             throw new IllegalArgumentException(
                     "Service ID must not be reused across registrations: " + serviceId);
@@ -397,7 +419,7 @@
 
         final ServiceRegistration registration = new ServiceRegistration(
                 mDeviceHostname, serviceInfo, NO_PACKET /* repliedServiceCount */,
-                NO_PACKET /* sentPacketCount */);
+                NO_PACKET /* sentPacketCount */, ttl);
         mServices.put(serviceId, registration);
 
         // Remove existing exiting service
@@ -776,7 +798,7 @@
                     true /* cacheFlush */,
                     // TODO: RFC6762 6.1: "In general, the TTL given for an NSEC record SHOULD
                     // be the same as the TTL that the record would have had, had it existed."
-                    NAME_RECORDS_TTL_MILLIS,
+                    DEFAULT_NAME_RECORDS_TTL_MILLIS,
                     question.getName(),
                     new int[] { question.getType() });
             additionalAnswerInfo.add(
@@ -903,22 +925,79 @@
         }
     }
 
+    @Nullable
+    public String getHostnameForServiceId(int id) {
+        ServiceRegistration registration = mServices.get(id);
+        if (registration == null) {
+            return null;
+        }
+        return registration.serviceInfo.getHostname();
+    }
+
+    /**
+     * Restart probing the services which are being probed and using the given custom hostname.
+     *
+     * @return The list of {@link MdnsProber.ProbingInfo} to be used by advertiser.
+     */
+    public List<MdnsProber.ProbingInfo> restartProbingForHostname(@NonNull String hostname) {
+        final ArrayList<MdnsProber.ProbingInfo> probingInfos = new ArrayList<>();
+        forEachActiveServiceRegistrationWithHostname(
+                hostname,
+                (id, registration) -> {
+                    if (!registration.isProbing) {
+                        return;
+                    }
+                    probingInfos.add(makeProbingInfo(id, registration));
+                });
+        return probingInfos;
+    }
+
+    /**
+     * Restart announcing the services which are using the given custom hostname.
+     *
+     * @return The list of {@link MdnsAnnouncer.AnnouncementInfo} to be used by advertiser.
+     */
+    public List<MdnsAnnouncer.AnnouncementInfo> restartAnnouncingForHostname(
+            @NonNull String hostname) {
+        final ArrayList<MdnsAnnouncer.AnnouncementInfo> announcementInfos = new ArrayList<>();
+        forEachActiveServiceRegistrationWithHostname(
+                hostname,
+                (id, registration) -> {
+                    if (registration.isProbing) {
+                        return;
+                    }
+                    announcementInfos.add(makeAnnouncementInfo(id, registration));
+                });
+        return announcementInfos;
+    }
+
     /**
      * Called to indicate that probing succeeded for a service.
+     *
      * @param probeSuccessInfo The successful probing info.
      * @return The {@link MdnsAnnouncer.AnnouncementInfo} to send, now that probing has succeeded.
      */
     public MdnsAnnouncer.AnnouncementInfo onProbingSucceeded(
-            MdnsProber.ProbingInfo probeSuccessInfo)
-            throws IOException {
-
-        int serviceId = probeSuccessInfo.getServiceId();
+            MdnsProber.ProbingInfo probeSuccessInfo) throws IOException {
+        final int serviceId = probeSuccessInfo.getServiceId();
         final ServiceRegistration registration = mServices.get(serviceId);
         if (registration == null) {
             throw new IOException("Service is not registered: " + serviceId);
         }
         registration.setProbing(false);
 
+        return makeAnnouncementInfo(serviceId, registration);
+    }
+
+    /**
+     * Make the announcement info of the given service ID.
+     *
+     * @param serviceId The service ID.
+     * @param registration The service registration.
+     * @return The {@link MdnsAnnouncer.AnnouncementInfo} of the given service ID.
+     */
+    private MdnsAnnouncer.AnnouncementInfo makeAnnouncementInfo(
+            int serviceId, ServiceRegistration registration) {
         final Set<MdnsRecord> answersSet = new LinkedHashSet<>();
         final ArrayList<MdnsRecord> additionalAnswers = new ArrayList<>();
 
@@ -950,8 +1029,8 @@
         addNsecRecordsForUniqueNames(additionalAnswers,
                 mGeneralRecords.iterator(), registration.allRecords.iterator());
 
-        return new MdnsAnnouncer.AnnouncementInfo(
-                probeSuccessInfo.getServiceId(), new ArrayList<>(answersSet), additionalAnswers);
+        return new MdnsAnnouncer.AnnouncementInfo(serviceId,
+                new ArrayList<>(answersSet), additionalAnswers);
     }
 
     /**
@@ -1211,7 +1290,7 @@
         if (existing == null) return null;
 
         final ServiceRegistration newService = new ServiceRegistration(mDeviceHostname, newInfo,
-                existing.repliedServiceCount, existing.sentPacketCount);
+                existing.repliedServiceCount, existing.sentPacketCount, existing.ttl);
         mServices.put(serviceId, newService);
         return makeProbingInfo(serviceId, newService);
     }
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsSearchOptions.java b/service-t/src/com/android/server/connectivity/mdns/MdnsSearchOptions.java
index 086094b..73405ab 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsSearchOptions.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsSearchOptions.java
@@ -59,6 +59,7 @@
                             source.readInt(),
                             source.readInt() == 1,
                             source.readParcelable(null),
+                            source.readInt(),
                             source.readString(),
                             source.readInt() == 1,
                             source.readInt());
@@ -79,6 +80,8 @@
     private final boolean removeExpiredService;
     // The target network for searching. Null network means search on all possible interfaces.
     @Nullable private final Network mNetwork;
+    // If the target interface does not have a Network, set to the interface index, otherwise unset.
+    private final int mInterfaceIndex;
 
     /** Parcelable constructs for a {@link MdnsSearchOptions}. */
     MdnsSearchOptions(
@@ -86,6 +89,7 @@
             int queryMode,
             boolean removeExpiredService,
             @Nullable Network network,
+            int interfaceIndex,
             @Nullable String resolveInstanceName,
             boolean onlyUseIpv6OnIpv6OnlyNetworks,
             int numOfQueriesBeforeBackoff) {
@@ -98,6 +102,7 @@
         this.numOfQueriesBeforeBackoff = numOfQueriesBeforeBackoff;
         this.removeExpiredService = removeExpiredService;
         mNetwork = network;
+        mInterfaceIndex = interfaceIndex;
         this.resolveInstanceName = resolveInstanceName;
     }
 
@@ -148,15 +153,27 @@
     }
 
     /**
-     * Returns the network which the mdns query should target on.
+     * Returns the network which the mdns query should target.
      *
-     * @return the target network or null if search on all possible interfaces.
+     * @return the target network or null to search on all possible interfaces.
      */
     @Nullable
     public Network getNetwork() {
         return mNetwork;
     }
 
+
+    /**
+     * Returns the interface index which the mdns query should target.
+     *
+     * This is only set when the service is to be searched on an interface that does not have a
+     * Network, in which case {@link #getNetwork()} returns null.
+     * The interface index as per {@link java.net.NetworkInterface#getIndex}, or 0 if unset.
+     */
+    public int getInterfaceIndex() {
+        return mInterfaceIndex;
+    }
+
     /**
      * If non-null, queries should try to resolve all records of this specific service, rather than
      * discovering all services.
@@ -177,6 +194,7 @@
         out.writeInt(queryMode);
         out.writeInt(removeExpiredService ? 1 : 0);
         out.writeParcelable(mNetwork, 0);
+        out.writeInt(mInterfaceIndex);
         out.writeString(resolveInstanceName);
         out.writeInt(onlyUseIpv6OnIpv6OnlyNetworks ? 1 : 0);
         out.writeInt(numOfQueriesBeforeBackoff);
@@ -190,6 +208,7 @@
         private int numOfQueriesBeforeBackoff = 3;
         private boolean removeExpiredService;
         private Network mNetwork;
+        private int mInterfaceIndex;
         private String resolveInstanceName;
 
         private Builder() {
@@ -278,6 +297,16 @@
             return this;
         }
 
+        /**
+         * Set the interface index to use for the query, if not querying on a {@link Network}.
+         *
+         * @see MdnsSearchOptions#getInterfaceIndex()
+         */
+        public Builder setInterfaceIndex(int index) {
+            mInterfaceIndex = index;
+            return this;
+        }
+
         /** Builds a {@link MdnsSearchOptions} with the arguments supplied to this builder. */
         public MdnsSearchOptions build() {
             return new MdnsSearchOptions(
@@ -285,6 +314,7 @@
                     queryMode,
                     removeExpiredService,
                     mNetwork,
+                    mInterfaceIndex,
                     resolveInstanceName,
                     onlyUseIpv6OnIpv6OnlyNetworks,
                     numOfQueriesBeforeBackoff);
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsServiceInfo.java b/service-t/src/com/android/server/connectivity/mdns/MdnsServiceInfo.java
index 78df6df..1ec9e39 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsServiceInfo.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsServiceInfo.java
@@ -28,6 +28,7 @@
 import com.android.net.module.util.ByteUtils;
 
 import java.nio.charset.Charset;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
@@ -62,7 +63,8 @@
                             source.createStringArrayList(),
                             source.createTypedArrayList(TextEntry.CREATOR),
                             source.readInt(),
-                            source.readParcelable(null));
+                            source.readParcelable(Network.class.getClassLoader()),
+                            Instant.ofEpochSecond(source.readLong()));
                 }
 
                 @Override
@@ -89,54 +91,8 @@
     @Nullable
     private final Network network;
 
-    /** Constructs a {@link MdnsServiceInfo} object with default values. */
-    public MdnsServiceInfo(
-            String serviceInstanceName,
-            String[] serviceType,
-            @Nullable List<String> subtypes,
-            String[] hostName,
-            int port,
-            @Nullable String ipv4Address,
-            @Nullable String ipv6Address,
-            @Nullable List<String> textStrings) {
-        this(
-                serviceInstanceName,
-                serviceType,
-                subtypes,
-                hostName,
-                port,
-                List.of(ipv4Address),
-                List.of(ipv6Address),
-                textStrings,
-                /* textEntries= */ null,
-                /* interfaceIndex= */ INTERFACE_INDEX_UNSPECIFIED,
-                /* network= */ null);
-    }
-
-    /** Constructs a {@link MdnsServiceInfo} object with default values. */
-    public MdnsServiceInfo(
-            String serviceInstanceName,
-            String[] serviceType,
-            List<String> subtypes,
-            String[] hostName,
-            int port,
-            @Nullable String ipv4Address,
-            @Nullable String ipv6Address,
-            @Nullable List<String> textStrings,
-            @Nullable List<TextEntry> textEntries) {
-        this(
-                serviceInstanceName,
-                serviceType,
-                subtypes,
-                hostName,
-                port,
-                List.of(ipv4Address),
-                List.of(ipv6Address),
-                textStrings,
-                textEntries,
-                /* interfaceIndex= */ INTERFACE_INDEX_UNSPECIFIED,
-                /* network= */ null);
-    }
+    @NonNull
+    private final Instant expirationTime;
 
     /**
      * Constructs a {@link MdnsServiceInfo} object with default values.
@@ -165,7 +121,8 @@
                 textStrings,
                 textEntries,
                 interfaceIndex,
-                /* network= */ null);
+                /* network= */ null,
+                /* expirationTime= */ Instant.MAX);
     }
 
     /**
@@ -184,7 +141,8 @@
             @Nullable List<String> textStrings,
             @Nullable List<TextEntry> textEntries,
             int interfaceIndex,
-            @Nullable Network network) {
+            @Nullable Network network,
+            @NonNull Instant expirationTime) {
         this.serviceInstanceName = serviceInstanceName;
         this.serviceType = serviceType;
         this.subtypes = new ArrayList<>();
@@ -217,6 +175,7 @@
         this.attributes = Collections.unmodifiableMap(attributes);
         this.interfaceIndex = interfaceIndex;
         this.network = network;
+        this.expirationTime = Instant.ofEpochSecond(expirationTime.getEpochSecond());
     }
 
     private static List<TextEntry> parseTextStrings(List<String> textStrings) {
@@ -314,6 +273,17 @@
     }
 
     /**
+     * Returns the timestamp after when this service is expired or {@code null} if the expiration
+     * time is unknown.
+     *
+     * A service is considered expired if any of its DNS record is expired.
+     */
+    @NonNull
+    public Instant getExpirationTime() {
+        return expirationTime;
+    }
+
+    /**
      * Returns attribute value for {@code key} as a UTF-8 string. It's the caller who must make sure
      * that the value of {@code key} is indeed a UTF-8 string. {@code null} will be returned if no
      * attribute value exists for {@code key}.
@@ -364,6 +334,7 @@
         out.writeTypedList(textEntries);
         out.writeInt(interfaceIndex);
         out.writeParcelable(network, 0);
+        out.writeLong(expirationTime.getEpochSecond());
     }
 
     @Override
@@ -377,7 +348,8 @@
                 + ", interfaceIndex: " + interfaceIndex
                 + ", network: " + network
                 + ", textStrings: " + textStrings
-                + ", textEntries: " + textEntries;
+                + ", textEntries: " + textEntries
+                + ", expirationTime: " + expirationTime;
     }
 
 
@@ -496,4 +468,4 @@
             out.writeByteArray(value);
         }
     }
-}
\ No newline at end of file
+}
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 4cb88b4..b3bdbe0 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsServiceTypeClient.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsServiceTypeClient.java
@@ -30,13 +30,19 @@
 import android.util.ArrayMap;
 import android.util.Pair;
 
-import com.android.internal.annotations.VisibleForTesting;
+import androidx.annotation.VisibleForTesting;
+
 import com.android.net.module.util.CollectionUtils;
 import com.android.net.module.util.SharedLog;
 import com.android.server.connectivity.mdns.util.MdnsUtils;
 
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.net.DatagramPacket;
 import java.net.Inet4Address;
 import java.net.Inet6Address;
+import java.net.InetSocketAddress;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
@@ -53,7 +59,6 @@
 public class MdnsServiceTypeClient {
 
     private static final String TAG = MdnsServiceTypeClient.class.getSimpleName();
-    private static final int DEFAULT_MTU = 1500;
     @VisibleForTesting
     static final int EVENT_START_QUERYTASK = 1;
     static final int EVENT_QUERY_RESULT = 2;
@@ -82,6 +87,7 @@
                     notifyRemovedServiceToListeners(previousResponse, "Service record expired");
                 }
             };
+    @NonNull private final MdnsFeatureFlags featureFlags;
     private final ArrayMap<MdnsServiceBrowserListener, ListenerInfo> listeners =
             new ArrayMap<>();
     private final boolean removeServiceAfterTtlExpires =
@@ -140,8 +146,8 @@
                     // before sending the query, it needs to be called just before sending it.
                     final List<MdnsResponse> servicesToResolve = makeResponsesForResolve(socketKey);
                     final QueryTask queryTask = new QueryTask(taskArgs, servicesToResolve,
-                            getAllDiscoverySubtypes(),
-                            servicesToResolve.size() < listeners.size() /* sendDiscoveryQueries */);
+                            getAllDiscoverySubtypes(), needSendDiscoveryQueries(listeners),
+                            getExistingServices());
                     executor.submit(queryTask);
                     break;
                 }
@@ -190,7 +196,7 @@
     /**
      * Dependencies of MdnsServiceTypeClient, for injection in tests.
      */
-    @VisibleForTesting
+    @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
     public static class Dependencies {
         /**
          * @see Handler#sendMessageDelayed(Message, long)
@@ -220,6 +226,25 @@
         public void sendMessage(@NonNull Handler handler, @NonNull Message message) {
             handler.sendMessage(message);
         }
+
+        /**
+         * Generate the DatagramPackets from given MdnsPacket and InetSocketAddress.
+         *
+         * <p> If the query with known answer feature is enabled and the MdnsPacket is too large for
+         *     a single DatagramPacket, it will be split into multiple DatagramPackets.
+         */
+        public List<DatagramPacket> getDatagramPacketsFromMdnsPacket(
+                @NonNull byte[] packetCreationBuffer, @NonNull MdnsPacket packet,
+                @NonNull InetSocketAddress address, boolean isQueryWithKnownAnswer)
+                throws IOException {
+            if (isQueryWithKnownAnswer) {
+                return MdnsUtils.createQueryDatagramPackets(packetCreationBuffer, packet, address);
+            } else {
+                final byte[] queryBuffer =
+                        MdnsUtils.createRawDnsPacket(packetCreationBuffer, packet);
+                return List.of(new DatagramPacket(queryBuffer, 0, queryBuffer.length, address));
+            }
+        }
     }
 
     /**
@@ -235,9 +260,10 @@
             @NonNull SocketKey socketKey,
             @NonNull SharedLog sharedLog,
             @NonNull Looper looper,
-            @NonNull MdnsServiceCache serviceCache) {
+            @NonNull MdnsServiceCache serviceCache,
+            @NonNull MdnsFeatureFlags featureFlags) {
         this(serviceType, socketClient, executor, new Clock(), socketKey, sharedLog, looper,
-                new Dependencies(), serviceCache);
+                new Dependencies(), serviceCache, featureFlags);
     }
 
     @VisibleForTesting
@@ -250,7 +276,8 @@
             @NonNull SharedLog sharedLog,
             @NonNull Looper looper,
             @NonNull Dependencies dependencies,
-            @NonNull MdnsServiceCache serviceCache) {
+            @NonNull MdnsServiceCache serviceCache,
+            @NonNull MdnsFeatureFlags featureFlags) {
         this.serviceType = serviceType;
         this.socketClient = socketClient;
         this.executor = executor;
@@ -264,6 +291,7 @@
         this.serviceCache = serviceCache;
         this.mdnsQueryScheduler = new MdnsQueryScheduler();
         this.cacheKey = new MdnsServiceCache.CacheKey(serviceType, socketKey);
+        this.featureFlags = featureFlags;
     }
 
     /**
@@ -309,6 +337,7 @@
             textStrings = response.getTextRecord().getStrings();
             textEntries = response.getTextRecord().getEntries();
         }
+        Instant now = Instant.now();
         // TODO: Throw an error message if response doesn't have Inet6 or Inet4 address.
         return new MdnsServiceInfo(
                 serviceInstanceName,
@@ -321,7 +350,13 @@
                 textStrings,
                 textEntries,
                 response.getInterfaceIndex(),
-                response.getNetwork());
+                response.getNetwork(),
+                now.plusMillis(response.getMinRemainingTtl(now.toEpochMilli())));
+    }
+
+    private List<MdnsResponse> getExistingServices() {
+        return featureFlags.isQueryWithKnownAnswerEnabled()
+                ? serviceCache.getCachedServices(cacheKey) : Collections.emptyList();
     }
 
     /**
@@ -388,8 +423,8 @@
             final QueryTask queryTask = new QueryTask(
                     mdnsQueryScheduler.scheduleFirstRun(taskConfig, now,
                             minRemainingTtl, currentSessionId), servicesToResolve,
-                    getAllDiscoverySubtypes(),
-                    servicesToResolve.size() < listeners.size() /* sendDiscoveryQueries */);
+                    getAllDiscoverySubtypes(), needSendDiscoveryQueries(listeners),
+                    getExistingServices());
             executor.submit(queryTask);
         }
 
@@ -615,11 +650,6 @@
         return searchOptions != null && searchOptions.removeExpiredService();
     }
 
-    @VisibleForTesting
-    MdnsPacketWriter createMdnsPacketWriter() {
-        return new MdnsPacketWriter(DEFAULT_MTU);
-    }
-
     private List<MdnsResponse> makeResponsesForResolve(@NonNull SocketKey socketKey) {
         final List<MdnsResponse> resolveResponses = new ArrayList<>();
         for (int i = 0; i < listeners.size(); i++) {
@@ -627,6 +657,10 @@
             if (resolveName == null) {
                 continue;
             }
+            if (CollectionUtils.any(resolveResponses,
+                    r -> MdnsUtils.equalsIgnoreDnsCase(resolveName, r.getServiceInstanceName()))) {
+                continue;
+            }
             MdnsResponse knownResponse =
                     serviceCache.getCachedService(resolveName, cacheKey);
             if (knownResponse == null) {
@@ -643,6 +677,17 @@
         return resolveResponses;
     }
 
+    private static boolean needSendDiscoveryQueries(
+            @NonNull ArrayMap<MdnsServiceBrowserListener, ListenerInfo> listeners) {
+        // Note iterators are discouraged on ArrayMap as per its documentation
+        for (int i = 0; i < listeners.size(); i++) {
+            if (listeners.valueAt(i).searchOptions.getResolveInstanceName() == null) {
+                return true;
+            }
+        }
+        return false;
+    }
+
     private void tryRemoveServiceAfterTtlExpires() {
         if (!shouldRemoveServiceAfterTtlExpires()) return;
 
@@ -677,14 +722,16 @@
         private final List<MdnsResponse> servicesToResolve = new ArrayList<>();
         private final List<String> subtypes = new ArrayList<>();
         private final boolean sendDiscoveryQueries;
+        private final List<MdnsResponse> existingServices = new ArrayList<>();
         QueryTask(@NonNull MdnsQueryScheduler.ScheduledQueryTaskArgs taskArgs,
                 @NonNull Collection<MdnsResponse> servicesToResolve,
-                @NonNull Collection<String> subtypes,
-                boolean sendDiscoveryQueries) {
+                @NonNull Collection<String> subtypes, boolean sendDiscoveryQueries,
+                @NonNull Collection<MdnsResponse> existingServices) {
             this.taskArgs = taskArgs;
             this.servicesToResolve.addAll(servicesToResolve);
             this.subtypes.addAll(subtypes);
             this.sendDiscoveryQueries = sendDiscoveryQueries;
+            this.existingServices.addAll(existingServices);
         }
 
         @Override
@@ -694,7 +741,6 @@
                 result =
                         new EnqueueMdnsQueryCallable(
                                 socketClient,
-                                createMdnsPacketWriter(),
                                 serviceType,
                                 subtypes,
                                 taskArgs.config.expectUnicastResponse,
@@ -704,7 +750,10 @@
                                 sendDiscoveryQueries,
                                 servicesToResolve,
                                 clock,
-                                sharedLog)
+                                sharedLog,
+                                dependencies,
+                                existingServices,
+                                featureFlags.isQueryWithKnownAnswerEnabled())
                                 .call();
             } catch (RuntimeException e) {
                 sharedLog.e(String.format("Failed to run EnqueueMdnsQueryCallable for subtype: %s",
@@ -743,4 +792,13 @@
                 args.sessionId, timeToNextTasksWithBackoffInMs));
         return timeToNextTasksWithBackoffInMs;
     }
+
+    /**
+     * Dump ServiceTypeClient state.
+     */
+    public void dump(PrintWriter pw) {
+        ensureRunningOnHandlerThread(handler);
+        pw.println("ServiceTypeClient: Type{" + serviceType + "} " + socketKey + " with "
+                + listeners.size() + " listeners.");
+    }
 }
\ No newline at end of file
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsSocket.java b/service-t/src/com/android/server/connectivity/mdns/MdnsSocket.java
index c51811b..653ea6c 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsSocket.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsSocket.java
@@ -58,7 +58,6 @@
     MdnsSocket(@NonNull MulticastNetworkInterfaceProvider multicastNetworkInterfaceProvider,
             MulticastSocket multicastSocket, SharedLog sharedLog) throws IOException {
         this.multicastNetworkInterfaceProvider = multicastNetworkInterfaceProvider;
-        this.multicastNetworkInterfaceProvider.startWatchingConnectivityChanges();
         this.multicastSocket = multicastSocket;
         this.sharedLog = sharedLog;
         // RFC Spec: https://tools.ietf.org/html/rfc6762
@@ -120,7 +119,6 @@
     public void close() {
         // This is a race with the use of the file descriptor (b/27403984).
         multicastSocket.close();
-        multicastNetworkInterfaceProvider.stopWatchingConnectivityChanges();
     }
 
     /**
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsSocketClient.java b/service-t/src/com/android/server/connectivity/mdns/MdnsSocketClient.java
index 82c8c5b..9cfcba1 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsSocketClient.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsSocketClient.java
@@ -25,6 +25,7 @@
 import android.net.wifi.WifiManager.MulticastLock;
 import android.os.SystemClock;
 import android.text.format.DateUtils;
+import android.util.Log;
 
 import com.android.internal.annotations.VisibleForTesting;
 import com.android.net.module.util.SharedLog;
@@ -106,6 +107,7 @@
     @Nullable private Timer checkMulticastResponseTimer;
     private final SharedLog sharedLog;
     @NonNull private final MdnsFeatureFlags mdnsFeatureFlags;
+    private final MulticastNetworkInterfaceProvider interfaceProvider;
 
     public MdnsSocketClient(@NonNull Context context, @NonNull MulticastLock multicastLock,
             SharedLog sharedLog, @NonNull MdnsFeatureFlags mdnsFeatureFlags) {
@@ -118,6 +120,7 @@
             unicastReceiverBuffer = null;
         }
         this.mdnsFeatureFlags = mdnsFeatureFlags;
+        this.interfaceProvider = new MulticastNetworkInterfaceProvider(context, sharedLog);
     }
 
     @Override
@@ -138,6 +141,7 @@
         cannotReceiveMulticastResponse.set(false);
 
         shouldStopSocketLoop = false;
+        interfaceProvider.startWatchingConnectivityChanges();
         try {
             // TODO (changed when importing code): consider setting thread stats tag
             multicastSocket = createMdnsSocket(MdnsConstants.MDNS_PORT, sharedLog);
@@ -183,6 +187,7 @@
         }
 
         multicastLock.release();
+        interfaceProvider.stopWatchingConnectivityChanges();
 
         shouldStopSocketLoop = true;
         waitForSendThreadToStop();
@@ -202,18 +207,18 @@
     }
 
     @Override
-    public void sendPacketRequestingMulticastResponse(@NonNull DatagramPacket packet,
+    public void sendPacketRequestingMulticastResponse(@NonNull List<DatagramPacket> packets,
             boolean onlyUseIpv6OnIpv6OnlyNetworks) {
-        sendMdnsPacket(packet, multicastPacketQueue, onlyUseIpv6OnIpv6OnlyNetworks);
+        sendMdnsPackets(packets, multicastPacketQueue, onlyUseIpv6OnIpv6OnlyNetworks);
     }
 
     @Override
-    public void sendPacketRequestingUnicastResponse(@NonNull DatagramPacket packet,
+    public void sendPacketRequestingUnicastResponse(@NonNull List<DatagramPacket> packets,
             boolean onlyUseIpv6OnIpv6OnlyNetworks) {
         if (useSeparateSocketForUnicast) {
-            sendMdnsPacket(packet, unicastPacketQueue, onlyUseIpv6OnIpv6OnlyNetworks);
+            sendMdnsPackets(packets, unicastPacketQueue, onlyUseIpv6OnIpv6OnlyNetworks);
         } else {
-            sendMdnsPacket(packet, multicastPacketQueue, onlyUseIpv6OnIpv6OnlyNetworks);
+            sendMdnsPackets(packets, multicastPacketQueue, onlyUseIpv6OnIpv6OnlyNetworks);
         }
     }
 
@@ -234,17 +239,21 @@
         return false;
     }
 
-    private void sendMdnsPacket(DatagramPacket packet, Queue<DatagramPacket> packetQueueToUse,
-            boolean onlyUseIpv6OnIpv6OnlyNetworks) {
+    private void sendMdnsPackets(List<DatagramPacket> packets,
+            Queue<DatagramPacket> packetQueueToUse, boolean onlyUseIpv6OnIpv6OnlyNetworks) {
         if (shouldStopSocketLoop && !MdnsConfigs.allowAddMdnsPacketAfterDiscoveryStops()) {
             sharedLog.w("sendMdnsPacket() is called after discovery already stopped");
             return;
         }
+        if (packets.isEmpty()) {
+            Log.wtf(TAG, "No mDns packets to send");
+            return;
+        }
 
-        final boolean isIpv4 = ((InetSocketAddress) packet.getSocketAddress()).getAddress()
-                instanceof Inet4Address;
-        final boolean isIpv6 = ((InetSocketAddress) packet.getSocketAddress()).getAddress()
-                instanceof Inet6Address;
+        final boolean isIpv4 = ((InetSocketAddress) packets.get(0).getSocketAddress())
+                .getAddress() instanceof Inet4Address;
+        final boolean isIpv6 = ((InetSocketAddress) packets.get(0).getSocketAddress())
+                .getAddress() instanceof Inet6Address;
         final boolean ipv6Only = multicastSocket != null && multicastSocket.isOnIPv6OnlyNetwork();
         if (isIpv4 && ipv6Only) {
             return;
@@ -254,10 +263,11 @@
         }
 
         synchronized (packetQueueToUse) {
-            while (packetQueueToUse.size() >= MdnsConfigs.mdnsPacketQueueMaxSize()) {
+            while ((packetQueueToUse.size() + packets.size())
+                    > MdnsConfigs.mdnsPacketQueueMaxSize()) {
                 packetQueueToUse.remove();
             }
-            packetQueueToUse.add(packet);
+            packetQueueToUse.addAll(packets);
         }
         triggerSendThread();
     }
@@ -482,8 +492,7 @@
 
     @VisibleForTesting
     MdnsSocket createMdnsSocket(int port, SharedLog sharedLog) throws IOException {
-        return new MdnsSocket(new MulticastNetworkInterfaceProvider(context, sharedLog), port,
-                sharedLog);
+        return new MdnsSocket(interfaceProvider, port, sharedLog);
     }
 
     private void sendPackets(List<DatagramPacket> packets, MdnsSocket socket) {
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsSocketClientBase.java b/service-t/src/com/android/server/connectivity/mdns/MdnsSocketClientBase.java
index b6000f0..b1a543a 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsSocketClientBase.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsSocketClientBase.java
@@ -23,6 +23,7 @@
 
 import java.io.IOException;
 import java.net.DatagramPacket;
+import java.util.List;
 
 /**
  * Base class for multicast socket client.
@@ -40,15 +41,15 @@
     void setCallback(@Nullable Callback callback);
 
     /**
-     * Send a mDNS request packet via given network that asks for multicast response.
+     * Send mDNS request packets via given network that asks for multicast response.
      */
-    void sendPacketRequestingMulticastResponse(@NonNull DatagramPacket packet,
+    void sendPacketRequestingMulticastResponse(@NonNull List<DatagramPacket> packets,
             boolean onlyUseIpv6OnIpv6OnlyNetworks);
 
     /**
-     * Send a mDNS request packet via given network that asks for unicast response.
+     * Send mDNS request packets via given network that asks for unicast response.
      */
-    void sendPacketRequestingUnicastResponse(@NonNull DatagramPacket packet,
+    void sendPacketRequestingUnicastResponse(@NonNull List<DatagramPacket> packets,
             boolean onlyUseIpv6OnIpv6OnlyNetworks);
 
     /*** Notify that the given network is requested for mdns discovery / resolution */
diff --git a/service-t/src/com/android/server/connectivity/mdns/util/MdnsUtils.java b/service-t/src/com/android/server/connectivity/mdns/util/MdnsUtils.java
index d553210..3c11a24 100644
--- a/service-t/src/com/android/server/connectivity/mdns/util/MdnsUtils.java
+++ b/service-t/src/com/android/server/connectivity/mdns/util/MdnsUtils.java
@@ -16,6 +16,8 @@
 
 package com.android.server.connectivity.mdns.util;
 
+import static com.android.server.connectivity.mdns.MdnsConstants.FLAG_TRUNCATED;
+
 import android.annotation.NonNull;
 import android.annotation.Nullable;
 import android.net.Network;
@@ -23,6 +25,7 @@
 import android.os.Handler;
 import android.os.SystemClock;
 import android.util.ArraySet;
+import android.util.Pair;
 
 import com.android.server.connectivity.mdns.MdnsConstants;
 import com.android.server.connectivity.mdns.MdnsPacket;
@@ -30,13 +33,18 @@
 import com.android.server.connectivity.mdns.MdnsRecord;
 
 import java.io.IOException;
+import java.net.DatagramPacket;
+import java.net.InetSocketAddress;
 import java.nio.ByteBuffer;
 import java.nio.CharBuffer;
 import java.nio.charset.Charset;
 import java.nio.charset.CharsetEncoder;
 import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.HashSet;
+import java.util.List;
 import java.util.Set;
 
 /**
@@ -226,6 +234,100 @@
     }
 
     /**
+     * Writes the possible query content of an MdnsPacket into the data buffer.
+     *
+     * <p>This method is specifically for query packets. It writes the question and answer sections
+     *    into the data buffer only.
+     *
+     * @param packetCreationBuffer The data buffer for the query content.
+     * @param packet The MdnsPacket to be written into the data buffer.
+     * @return A Pair containing:
+     *         1. The remaining MdnsPacket data that could not fit in the buffer.
+     *         2. The length of the data written to the buffer.
+     */
+    @Nullable
+    private static Pair<MdnsPacket, Integer> writePossibleMdnsPacket(
+            @NonNull byte[] packetCreationBuffer, @NonNull MdnsPacket packet) throws IOException {
+        MdnsPacket remainingPacket;
+        final MdnsPacketWriter writer = new MdnsPacketWriter(packetCreationBuffer);
+        writer.writeUInt16(packet.transactionId); // Transaction ID
+
+        final int flagsPos = writer.getWritePosition();
+        writer.writeUInt16(0); // Flags, written later
+        writer.writeUInt16(0); // questions count, written later
+        writer.writeUInt16(0); // answers count, written later
+        writer.writeUInt16(0); // authority entries count, empty session for query
+        writer.writeUInt16(0); // additional records count, empty session for query
+
+        int writtenQuestions = 0;
+        int writtenAnswers = 0;
+        int lastValidPos = writer.getWritePosition();
+        try {
+            for (MdnsRecord record : packet.questions) {
+                // Questions do not have TTL or data
+                record.writeHeaderFields(writer);
+                writtenQuestions++;
+                lastValidPos = writer.getWritePosition();
+            }
+            for (MdnsRecord record : packet.answers) {
+                record.write(writer, 0L);
+                writtenAnswers++;
+                lastValidPos = writer.getWritePosition();
+            }
+            remainingPacket = null;
+        } catch (IOException e) {
+            // Went over the packet limit; truncate
+            if (writtenQuestions == 0 && writtenAnswers == 0) {
+                // No space to write even one record: just throw (as subclass of IOException)
+                throw e;
+            }
+
+            // Set the last valid position as the final position (not as a rewind)
+            writer.rewind(lastValidPos);
+            writer.clearRewind();
+
+            remainingPacket = new MdnsPacket(packet.flags,
+                    packet.questions.subList(
+                            writtenQuestions, packet.questions.size()),
+                    packet.answers.subList(
+                            writtenAnswers, packet.answers.size()),
+                    Collections.emptyList(), /* authorityRecords */
+                    Collections.emptyList() /* additionalRecords */);
+        }
+
+        final int len = writer.getWritePosition();
+        writer.rewind(flagsPos);
+        writer.writeUInt16(packet.flags | (remainingPacket == null ? 0 : FLAG_TRUNCATED));
+        writer.writeUInt16(writtenQuestions);
+        writer.writeUInt16(writtenAnswers);
+        writer.unrewind();
+
+        return Pair.create(remainingPacket, len);
+    }
+
+    /**
+     * Create Datagram packets from given MdnsPacket and InetSocketAddress.
+     *
+     * <p> If the MdnsPacket is too large for a single DatagramPacket, it will be split into
+     *     multiple DatagramPackets.
+     */
+    public static List<DatagramPacket> createQueryDatagramPackets(
+            @NonNull byte[] packetCreationBuffer, @NonNull MdnsPacket packet,
+            @NonNull InetSocketAddress destination) throws IOException {
+        final List<DatagramPacket> datagramPackets = new ArrayList<>();
+        MdnsPacket remainingPacket = packet;
+        while (remainingPacket != null) {
+            final Pair<MdnsPacket, Integer> result =
+                    writePossibleMdnsPacket(packetCreationBuffer, remainingPacket);
+            remainingPacket = result.first;
+            final int len = result.second;
+            final byte[] outBuffer = Arrays.copyOfRange(packetCreationBuffer, 0, len);
+            datagramPackets.add(new DatagramPacket(outBuffer, 0, outBuffer.length, destination));
+        }
+        return datagramPackets;
+    }
+
+    /**
      * Checks if the MdnsRecord needs to be renewed or not.
      *
      * <p>As per RFC6762 7.1 no need to query if remaining TTL is more than half the original one,
diff --git a/service-t/src/com/android/server/ethernet/EthernetNetworkFactory.java b/service-t/src/com/android/server/ethernet/EthernetNetworkFactory.java
index 0b54fdd..cadc04d 100644
--- a/service-t/src/com/android/server/ethernet/EthernetNetworkFactory.java
+++ b/service-t/src/com/android/server/ethernet/EthernetNetworkFactory.java
@@ -250,6 +250,17 @@
         return mTrackingInterfaces.containsKey(ifaceName);
     }
 
+    @VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE)
+    @Nullable
+    protected String getHwAddress(@NonNull final String ifaceName) {
+        if (!hasInterface(ifaceName)) {
+            return null;
+        }
+
+        NetworkInterfaceState iface = mTrackingInterfaces.get(ifaceName);
+        return iface.mHwAddress;
+    }
+
     @VisibleForTesting
     static class NetworkInterfaceState {
         final String name;
diff --git a/service-t/src/com/android/server/ethernet/EthernetTracker.java b/service-t/src/com/android/server/ethernet/EthernetTracker.java
index 458d64f..9c8fd99 100644
--- a/service-t/src/com/android/server/ethernet/EthernetTracker.java
+++ b/service-t/src/com/android/server/ethernet/EthernetTracker.java
@@ -382,10 +382,9 @@
         });
     }
 
-    @VisibleForTesting(visibility = PACKAGE)
-    protected void setInterfaceEnabled(@NonNull final String iface, boolean enabled,
-            @Nullable final EthernetCallback cb) {
-        mHandler.post(() -> updateInterfaceState(iface, enabled, cb));
+    /** Configure the administrative state of ethernet interface by toggling IFF_UP. */
+    public void setInterfaceEnabled(String iface, boolean enabled, EthernetCallback cb) {
+        mHandler.post(() -> setInterfaceAdministrativeState(iface, enabled, cb));
     }
 
     IpConfiguration getIpConfiguration(String iface) {
@@ -461,7 +460,7 @@
             if (!include) {
                 removeTestData();
             }
-            mHandler.post(() -> trackAvailableInterfaces());
+            trackAvailableInterfaces();
         });
     }
 
@@ -643,25 +642,40 @@
         }
     }
 
-    private void updateInterfaceState(String iface, boolean up) {
-        updateInterfaceState(iface, up, new EthernetCallback(null /* cb */));
-    }
-
-    // TODO(b/225315248): enable/disableInterface() should not affect link state.
-    private void updateInterfaceState(String iface, boolean up, EthernetCallback cb) {
-        final int mode = getInterfaceMode(iface);
-        if (mode == INTERFACE_MODE_SERVER || !mFactory.hasInterface(iface)) {
-            // The interface is in server mode or is not tracked.
-            cb.onError("Failed to set link state " + (up ? "up" : "down") + " for " + iface);
+    private void setInterfaceAdministrativeState(String iface, boolean up, EthernetCallback cb) {
+        if (getInterfaceState(iface) == EthernetManager.STATE_ABSENT) {
+            cb.onError("Failed to enable/disable absent interface: " + iface);
+            return;
+        }
+        if (getInterfaceRole(iface) == EthernetManager.ROLE_SERVER) {
+            // TODO: support setEthernetState for server mode interfaces.
+            cb.onError("Failed to enable/disable interface in server mode: " + iface);
             return;
         }
 
+        if (up) {
+            // WARNING! setInterfaceUp() clears the IPv4 address and readds it. Calling
+            // enableInterface() on an active interface can lead to a provisioning failure which
+            // will cause IpClient to be restarted.
+            // TODO: use netlink directly rather than calling into netd.
+            NetdUtils.setInterfaceUp(mNetd, iface);
+        } else {
+            NetdUtils.setInterfaceDown(mNetd, iface);
+        }
+        cb.onResult(iface);
+    }
+
+    private void updateInterfaceState(String iface, boolean up) {
+        final int mode = getInterfaceMode(iface);
+        if (mode == INTERFACE_MODE_SERVER) {
+            // TODO: support tracking link state for interfaces in server mode.
+            return;
+        }
+
+        // If updateInterfaceLinkState returns false, the interface is already in the correct state.
         if (mFactory.updateInterfaceLinkState(iface, up)) {
             broadcastInterfaceStateChange(iface);
         }
-        // If updateInterfaceLinkState returns false, the interface is already in the correct state.
-        // Always return success.
-        cb.onResult(iface);
     }
 
     private void maybeUpdateServerModeInterfaceState(String iface, boolean available) {
diff --git a/service-t/src/com/android/server/net/NetworkStatsService.java b/service-t/src/com/android/server/net/NetworkStatsService.java
index edffab0..4438b87 100644
--- a/service-t/src/com/android/server/net/NetworkStatsService.java
+++ b/service-t/src/com/android/server/net/NetworkStatsService.java
@@ -1783,6 +1783,8 @@
             if (transport == TRANSPORT_WIFI) {
                 ifaceSet = mAllWifiIfacesSinceBoot;
             } else if (transport == TRANSPORT_CELLULAR) {
+                // Since satellite networks appear under type mobile, this includes both cellular
+                // and satellite active interfaces
                 ifaceSet = mAllMobileIfacesSinceBoot;
             } else {
                 throw new IllegalArgumentException("Invalid transport " + transport);
@@ -2193,7 +2195,9 @@
         for (NetworkStateSnapshot snapshot : snapshots) {
             final int displayTransport =
                     getDisplayTransport(snapshot.getNetworkCapabilities().getTransportTypes());
-            final boolean isMobile = (NetworkCapabilities.TRANSPORT_CELLULAR == displayTransport);
+            // Consider satellite transport to support satellite stats appear as type_mobile
+            final boolean isMobile = NetworkCapabilities.TRANSPORT_CELLULAR == displayTransport
+                    || NetworkCapabilities.TRANSPORT_SATELLITE == displayTransport;
             final boolean isWifi = (NetworkCapabilities.TRANSPORT_WIFI == displayTransport);
             final boolean isDefault = CollectionUtils.contains(
                     mDefaultNetworks, snapshot.getNetwork());
@@ -2236,7 +2240,7 @@
                             .setDefaultNetwork(true)
                             .setOemManaged(ident.getOemManaged())
                             .setSubId(ident.getSubId()).build();
-                    final String ifaceVt = IFACE_VT + getSubIdForMobile(snapshot);
+                    final String ifaceVt = IFACE_VT + getSubIdForCellularOrSatellite(snapshot);
                     findOrCreateNetworkIdentitySet(mActiveIfaces, ifaceVt).add(vtIdent);
                     findOrCreateNetworkIdentitySet(mActiveUidIfaces, ifaceVt).add(vtIdent);
                 }
@@ -2305,9 +2309,15 @@
         mMobileIfaces = mobileIfaces.toArray(new String[0]);
     }
 
-    private static int getSubIdForMobile(@NonNull NetworkStateSnapshot state) {
-        if (!state.getNetworkCapabilities().hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
-            throw new IllegalArgumentException("Mobile state need capability TRANSPORT_CELLULAR");
+    private static int getSubIdForCellularOrSatellite(@NonNull NetworkStateSnapshot state) {
+        if (!state.getNetworkCapabilities().hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
+                // Both cellular and satellite are 2 different network transport at Mobile using
+                // same telephony network specifier. So adding satellite transport to consider
+                // for, when satellite network is active at mobile.
+                && !state.getNetworkCapabilities().hasTransport(
+                NetworkCapabilities.TRANSPORT_SATELLITE)) {
+            throw new IllegalArgumentException(
+                    "Mobile state need capability TRANSPORT_CELLULAR or TRANSPORT_SATELLITE");
         }
 
         final NetworkSpecifier spec = state.getNetworkCapabilities().getNetworkSpecifier();
@@ -2320,12 +2330,14 @@
     }
 
     /**
-     * For networks with {@code TRANSPORT_CELLULAR}, get ratType that was obtained through
-     * {@link PhoneStateListener}. Otherwise, return 0 given that other networks with different
-     * transport types do not actually fill this value.
+     * For networks with {@code TRANSPORT_CELLULAR} Or {@code TRANSPORT_SATELLITE}, get ratType
+     * that was obtained through {@link PhoneStateListener}. Otherwise, return 0 given that other
+     * networks with different transport types do not actually fill this value.
      */
     private int getRatTypeForStateSnapshot(@NonNull NetworkStateSnapshot state) {
-        if (!state.getNetworkCapabilities().hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
+        if (!state.getNetworkCapabilities().hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
+                && !state.getNetworkCapabilities()
+                .hasTransport(NetworkCapabilities.TRANSPORT_SATELLITE)) {
             return 0;
         }
 
diff --git a/service-t/src/com/android/server/net/TrafficStatsRateLimitCache.java b/service-t/src/com/android/server/net/TrafficStatsRateLimitCache.java
index 8598ac4..ca97d07 100644
--- a/service-t/src/com/android/server/net/TrafficStatsRateLimitCache.java
+++ b/service-t/src/com/android/server/net/TrafficStatsRateLimitCache.java
@@ -19,12 +19,13 @@
 import android.annotation.NonNull;
 import android.annotation.Nullable;
 import android.net.NetworkStats;
+import android.util.LruCache;
 
 import com.android.internal.annotations.GuardedBy;
 
 import java.time.Clock;
-import java.util.HashMap;
 import java.util.Objects;
+import java.util.function.Supplier;
 
 /**
  * A thread-safe cache for storing and retrieving {@link NetworkStats.Entry} objects,
@@ -39,10 +40,12 @@
      *
      * @param clock The {@link Clock} to use for determining timestamps.
      * @param expiryDurationMs The expiry duration in milliseconds.
+     * @param maxSize Maximum number of entries.
      */
-    TrafficStatsRateLimitCache(@NonNull Clock clock, long expiryDurationMs) {
+    TrafficStatsRateLimitCache(@NonNull Clock clock, long expiryDurationMs, int maxSize) {
         mClock = clock;
         mExpiryDurationMs = expiryDurationMs;
+        mMap = new LruCache<>(maxSize);
     }
 
     private static class TrafficStatsCacheKey {
@@ -81,7 +84,7 @@
     }
 
     @GuardedBy("mMap")
-    private final HashMap<TrafficStatsCacheKey, TrafficStatsCacheValue> mMap = new HashMap<>();
+    private final LruCache<TrafficStatsCacheKey, TrafficStatsCacheValue> mMap;
 
     /**
      * Retrieves a {@link NetworkStats.Entry} from the cache, associated with the given key.
@@ -105,6 +108,36 @@
     }
 
     /**
+     * Retrieves a {@link NetworkStats.Entry} from the cache, associated with the given key.
+     * If the entry is not found in the cache or has expired, computes it using the provided
+     * {@code supplier} and stores the result in the cache.
+     *
+     * @param iface The interface name to include in the cache key. {@code IFACE_ALL}
+     *              if not applicable.
+     * @param uid The UID to include in the cache key. {@code UID_ALL} if not applicable.
+     * @param supplier The {@link Supplier} to compute the {@link NetworkStats.Entry} if not found.
+     * @return The cached or computed {@link NetworkStats.Entry}, or null if not found, expired,
+     *         or if the {@code supplier} returns null.
+     */
+    @Nullable
+    NetworkStats.Entry getOrCompute(String iface, int uid,
+            @NonNull Supplier<NetworkStats.Entry> supplier) {
+        synchronized (mMap) {
+            final NetworkStats.Entry cachedValue = get(iface, uid);
+            if (cachedValue != null) {
+                return cachedValue;
+            }
+
+            // Entry not found or expired, compute it
+            final NetworkStats.Entry computedEntry = supplier.get();
+            if (computedEntry != null && !computedEntry.isEmpty()) {
+                put(iface, uid, computedEntry);
+            }
+            return computedEntry;
+        }
+    }
+
+    /**
      * Stores a {@link NetworkStats.Entry} in the cache, associated with the given key.
      *
      * @param iface The interface name to include in the cache key. Null if not applicable.
@@ -124,7 +157,7 @@
      */
     void clear() {
         synchronized (mMap) {
-            mMap.clear();
+            mMap.evictAll();
         }
     }
 
diff --git a/service/Android.bp b/service/Android.bp
index 31acced..7c22ca5 100644
--- a/service/Android.bp
+++ b/service/Android.bp
@@ -199,6 +199,9 @@
         "PlatformProperties",
         "service-connectivity-protos",
         "service-connectivity-stats-protos",
+        // The required dependency net-utils-device-common-struct-base is in the classpath via
+        // framework-connectivity
+        "net-utils-device-common-struct",
     ],
     apex_available: [
         "com.android.tethering",
diff --git a/service/ServiceConnectivityResources/res/values-de/strings.xml b/service/ServiceConnectivityResources/res/values-de/strings.xml
index 536ebda..f58efb0 100644
--- a/service/ServiceConnectivityResources/res/values-de/strings.xml
+++ b/service/ServiceConnectivityResources/res/values-de/strings.xml
@@ -29,7 +29,7 @@
     <string name="wifi_no_internet_detailed" msgid="1746921096565304090">"Für Optionen tippen"</string>
     <string name="mobile_no_internet" msgid="4087718456753201450">"Mobiles Netzwerk hat keinen Internetzugriff"</string>
     <string name="other_networks_no_internet" msgid="5693932964749676542">"Netzwerk hat keinen Internetzugriff"</string>
-    <string name="private_dns_broken_detailed" msgid="2677123850463207823">"Auf den privaten DNS-Server kann nicht zugegriffen werden"</string>
+    <string name="private_dns_broken_detailed" msgid="2677123850463207823">"Auf den Server des privaten DNS kann nicht zugegriffen werden"</string>
     <string name="network_partial_connectivity" msgid="5549503845834993258">"Schlechte Verbindung mit <xliff:g id="NETWORK_SSID">%1$s</xliff:g>"</string>
     <string name="network_partial_connectivity_detailed" msgid="4732435946300249845">"Tippen, um die Verbindung trotzdem herzustellen"</string>
     <string name="network_switch_metered" msgid="5016937523571166319">"Zu <xliff:g id="NETWORK_TYPE">%1$s</xliff:g> gewechselt"</string>
diff --git a/service/ServiceConnectivityResources/res/values/config_thread.xml b/service/ServiceConnectivityResources/res/values/config_thread.xml
index 14b5427..4783f2b 100644
--- a/service/ServiceConnectivityResources/res/values/config_thread.xml
+++ b/service/ServiceConnectivityResources/res/values/config_thread.xml
@@ -20,10 +20,37 @@
 -->
 
 <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
+    <!-- Sets to {@code true} to enable Thread on the device by default. Note this is the default
+    value, the actual Thread enabled state can be changed by the {@link
+    ThreadNetworkController#setEnabled} API.
+    -->
+    <bool name="config_thread_default_enabled">true</bool>
+
     <!-- Whether to use location APIs in the algorithm to determine country code or not.
     If disabled, will use other sources (telephony, wifi, etc) to determine device location for
     Thread Network regulatory purposes.
     -->
     <bool name="config_thread_location_use_for_country_code_enabled">true</bool>
 
+    <!-- Specifies the UTF-8 vendor name of this device. If this value is not an empty string, it
+    will be included in TXT value (key is 'vn') of the "_meshcop._udp" mDNS service which is
+    published by the Thread service. A non-empty string value must not exceed length of 24 UTF-8
+    bytes.
+    -->
+    <string translatable="false" name="config_thread_vendor_name">Android</string>
+
+    <!-- Specifies the 24 bits vendor OUI of this device. If this value is not an empty string, it
+    will be included in TXT (key is 'vo') value of the "_meshcop._udp" mDNS service which is
+    published by the Thread service. The OUI can be represented as a base-16 number of six
+    hexadecimal digits, or octets separated by hyphens or dots. For example, "ACDE48", "AC-DE-48"
+    and "AC:DE:48" are all valid representations of the same OUI value.
+    -->
+    <string translatable="false" name="config_thread_vendor_oui"></string>
+
+    <!-- Specifies the UTF-8 product model name of this device. If this value is not an empty
+    string, it will be included in TXT (key is 'mn') value of the "_meshcop._udp" mDNS service
+    which is published by the Thread service. A non-empty string value must not exceed length of 24
+    UTF-8 bytes.
+    -->
+    <string translatable="false" name="config_thread_model_name">Thread Border Router</string>
 </resources>
diff --git a/service/ServiceConnectivityResources/res/values/overlayable.xml b/service/ServiceConnectivityResources/res/values/overlayable.xml
index f2c4d91..158b0c8 100644
--- a/service/ServiceConnectivityResources/res/values/overlayable.xml
+++ b/service/ServiceConnectivityResources/res/values/overlayable.xml
@@ -46,7 +46,11 @@
             <item type="integer" name="config_netstats_validate_import" />
 
             <!-- Configuration values for ThreadNetworkService -->
+            <item type="bool" name="config_thread_default_enabled" />
             <item type="bool" name="config_thread_location_use_for_country_code_enabled" />
+            <item type="string" name="config_thread_vendor_name" />
+            <item type="string" name="config_thread_vendor_oui" />
+            <item type="string" name="config_thread_model_name" />
         </policy>
     </overlayable>
 </resources>
diff --git a/service/jni/com_android_server_connectivity_ClatCoordinator.cpp b/service/jni/com_android_server_connectivity_ClatCoordinator.cpp
index c125bd6..4214bc9 100644
--- a/service/jni/com_android_server_connectivity_ClatCoordinator.cpp
+++ b/service/jni/com_android_server_connectivity_ClatCoordinator.cpp
@@ -113,7 +113,12 @@
     if (!modules::sdklevel::IsAtLeastT()) return;
 
     V("/sys/fs/bpf", S_IFDIR|S_ISVTX|0777, ROOT, ROOT, "fs_bpf", DIR);
-    V("/sys/fs/bpf/net_shared", S_IFDIR|S_ISVTX|0777, ROOT, ROOT, "fs_bpf_net_shared", DIR);
+
+    if (false && modules::sdklevel::IsAtLeastV()) {
+        V("/sys/fs/bpf/net_shared", S_IFDIR|01777, ROOT, ROOT, "fs_bpf_net_shared", DIR);
+    } else {
+        V("/sys/fs/bpf/net_shared", S_IFDIR|01777, SYSTEM, SYSTEM, "fs_bpf_net_shared", DIR);
+    }
 
     // pre-U we do not have selinux privs to getattr on bpf maps/progs
     // so while the below *should* be as listed, we have no way to actually verify
diff --git a/service/src/com/android/server/BpfLoaderRcUtils.java b/service/src/com/android/server/BpfLoaderRcUtils.java
deleted file mode 100644
index 293e757..0000000
--- a/service/src/com/android/server/BpfLoaderRcUtils.java
+++ /dev/null
@@ -1,161 +0,0 @@
-/*
- * 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.annotation.NonNull;
-import android.util.Log;
-
-import com.android.internal.annotations.VisibleForTesting;
-import com.android.modules.utils.build.SdkLevel;
-
-import java.io.BufferedReader;
-import java.io.File;
-import java.io.FileInputStream;
-import java.io.FileNotFoundException;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.InputStreamReader;
-import java.nio.charset.StandardCharsets;
-import java.util.ArrayList;
-import java.util.List;
-
-/**
- * BpfRcUtils is responsible for comparing the bpf loader rc file.
- *
- * {@hide}
- */
-public class BpfLoaderRcUtils {
-    public static final String TAG = BpfLoaderRcUtils.class.getSimpleName();
-
-    private static final List<String> BPF_LOADER_RC_S_T = List.of(
-            "service bpfloader /system/bin/bpfloader",
-            "capabilities CHOWN SYS_ADMIN NET_ADMIN",
-            "rlimit memlock 1073741824 1073741824",
-            "oneshot",
-            "reboot_on_failure reboot,bpfloader-failed",
-            "updatable"
-    );
-
-    private static final List<String> BPF_LOADER_RC_U = List.of(
-            "service bpfloader /system/bin/bpfloader",
-            "capabilities CHOWN SYS_ADMIN NET_ADMIN",
-            "group root graphics network_stack net_admin net_bw_acct net_bw_stats net_raw system",
-            "user root",
-            "rlimit memlock 1073741824 1073741824",
-            "oneshot",
-            "reboot_on_failure reboot,bpfloader-failed",
-            "updatable"
-    );
-
-    private static final List<String> BPF_LOADER_RC_UQPR2 = List.of(
-            "service bpfloader /system/bin/netbpfload",
-            "capabilities CHOWN SYS_ADMIN NET_ADMIN",
-            "group root graphics network_stack net_admin net_bw_acct net_bw_stats net_raw system",
-            "user root",
-            "rlimit memlock 1073741824 1073741824",
-            "oneshot",
-            "reboot_on_failure reboot,bpfloader-failed",
-            "updatable"
-    );
-
-
-    private static final String BPF_LOADER_RC_FILE_PATH = "/etc/init/bpfloader.rc";
-    private static final String NET_BPF_LOAD_RC_FILE_PATH = "/etc/init/netbpfload.rc";
-
-    private BpfLoaderRcUtils() {
-    }
-
-    /**
-     * Load the bpf rc file content from the input stream.
-     */
-    @VisibleForTesting
-    public static List<String> loadExistingBpfRcFile(@NonNull InputStream inputStream) {
-        List<String> contents = new ArrayList<>();
-        boolean bpfSectionFound = false;
-        try (BufferedReader br = new BufferedReader(
-                new InputStreamReader(inputStream, StandardCharsets.ISO_8859_1))) {
-            String line;
-            while ((line = br.readLine()) != null) {
-                line = line.trim();
-                if (line.isEmpty()) {
-                    continue;
-                }
-                if (line.startsWith("#")) {
-                    continue;
-                }
-                // If bpf service section was found and new service or action section start. The
-                // read should stop.
-                if (bpfSectionFound && (line.startsWith("service ") || (line.startsWith("on ")))) {
-                    break;
-                }
-                if (line.startsWith("service bpfloader ")) {
-                    bpfSectionFound = true;
-                }
-                if (bpfSectionFound) {
-                    contents.add(line);
-                }
-            }
-        } catch (IOException e) {
-            Log.wtf("read input stream failed.", e);
-            contents.clear();
-            return contents;
-        }
-        return contents;
-    }
-
-    /**
-     * Check the bpfLoader rc file on the system image matches any of the template files.
-     */
-    public static boolean checkBpfLoaderRc() {
-        File bpfRcFile = new File(BPF_LOADER_RC_FILE_PATH);
-        if (!bpfRcFile.exists()) {
-            if (SdkLevel.isAtLeastU()) {
-                bpfRcFile = new File(NET_BPF_LOAD_RC_FILE_PATH);
-            }
-            if (!bpfRcFile.exists()) {
-                Log.wtf(TAG,
-                        "neither " + BPF_LOADER_RC_FILE_PATH + " nor " + NET_BPF_LOAD_RC_FILE_PATH
-                                + " exist.");
-                return false;
-            }
-            // Check bpf rc file in U QPR2
-            return compareBpfLoaderRc(bpfRcFile, BPF_LOADER_RC_UQPR2);
-        }
-
-        if (SdkLevel.isAtLeastU()) {
-            // Check bpf rc file in U
-            return compareBpfLoaderRc(bpfRcFile, BPF_LOADER_RC_U);
-        }
-        // Check bpf rc file in S/T
-        return compareBpfLoaderRc(bpfRcFile, BPF_LOADER_RC_S_T);
-    }
-
-    private static boolean compareBpfLoaderRc(@NonNull File bpfRcFile,
-            @NonNull List<String> template) {
-        try {
-            List<String> actualContent = loadExistingBpfRcFile(new FileInputStream(bpfRcFile));
-            if (!actualContent.equals(template)) {
-                Log.wtf(TAG, "BPF rc file is not same as the template files " + actualContent);
-                return false;
-            }
-        } catch (FileNotFoundException e) {
-            Log.wtf(bpfRcFile.getPath() + " doesn't exist.", e);
-            return false;
-        }
-        return true;
-    }
-}
diff --git a/service/src/com/android/server/BpfNetMaps.java b/service/src/com/android/server/BpfNetMaps.java
index a7fddd0..42c1628 100644
--- a/service/src/com/android/server/BpfNetMaps.java
+++ b/service/src/com/android/server/BpfNetMaps.java
@@ -49,7 +49,7 @@
 
 import android.app.StatsManager;
 import android.content.Context;
-import android.net.BpfNetMapsReader;
+import android.net.BpfNetMapsUtils;
 import android.net.INetd;
 import android.net.UidOwnerValue;
 import android.os.Build;
@@ -535,14 +535,11 @@
      * @throws UnsupportedOperationException if called on pre-T devices.
      * @throws ServiceSpecificException in case of failure, with an error code indicating the
      *                                  cause of the failure.
-     *
-     * @deprecated Use {@link BpfNetMapsReader#isChainEnabled} instead.
      */
-    // TODO: Migrate the callers to use {@link BpfNetMapsReader#isChainEnabled} instead.
     @Deprecated
     @RequiresApi(Build.VERSION_CODES.TIRAMISU)
     public boolean isChainEnabled(final int childChain) {
-        return BpfNetMapsReader.isChainEnabled(sConfigurationMap, childChain);
+        return BpfNetMapsUtils.isChainEnabled(sConfigurationMap, childChain);
     }
 
     private Set<Integer> asSet(final int[] uids) {
@@ -635,12 +632,9 @@
      * @throws UnsupportedOperationException if called on pre-T devices.
      * @throws ServiceSpecificException in case of failure, with an error code indicating the
      *                                  cause of the failure.
-     *
-     * @deprecated use {@link BpfNetMapsReader#getUidRule} instead.
      */
-    // TODO: Migrate the callers to use {@link BpfNetMapsReader#getUidRule} instead.
     public int getUidRule(final int childChain, final int uid) {
-        return BpfNetMapsReader.getUidRule(sUidOwnerMap, childChain, uid);
+        return BpfNetMapsUtils.getUidRule(sUidOwnerMap, childChain, uid);
     }
 
     private Set<Integer> getUidsMatchEnabled(final int childChain) throws ErrnoException {
@@ -924,6 +918,25 @@
         }
     }
 
+    /**
+     * Return whether the network is blocked by firewall chains for the given uid.
+     *
+     * Note that {@link #getDataSaverEnabled()} has a latency before V.
+     *
+     * @param uid The target uid.
+     * @param isNetworkMetered Whether the target network is metered.
+     *
+     * @return True if the network is blocked. Otherwise, false.
+     * @throws ServiceSpecificException if the read fails.
+     *
+     * @hide
+     */
+    @RequiresApi(Build.VERSION_CODES.TIRAMISU)
+    public boolean isUidNetworkingBlocked(final int uid, boolean isNetworkMetered) {
+        return BpfNetMapsUtils.isUidNetworkingBlocked(uid, isNetworkMetered,
+                sConfigurationMap, sUidOwnerMap, sDataSaverEnabledMap);
+    }
+
     /** Register callback for statsd to pull atom. */
     @RequiresApi(Build.VERSION_CODES.TIRAMISU)
     public void setPullAtomCallback(final Context context) {
diff --git a/service/src/com/android/server/ConnectivityService.java b/service/src/com/android/server/ConnectivityService.java
index 6839c22..b1ae019 100755
--- a/service/src/com/android/server/ConnectivityService.java
+++ b/service/src/com/android/server/ConnectivityService.java
@@ -97,6 +97,8 @@
 import static android.net.NetworkScore.POLICY_TRANSPORT_PRIMARY;
 import static android.net.OemNetworkPreferences.OEM_NETWORK_PREFERENCE_TEST;
 import static android.net.OemNetworkPreferences.OEM_NETWORK_PREFERENCE_TEST_ONLY;
+import static android.net.connectivity.ConnectivityCompatChanges.ENABLE_MATCH_LOCAL_NETWORK;
+import static android.net.connectivity.ConnectivityCompatChanges.ENABLE_SELF_CERTIFIED_CAPABILITIES_DECLARATION;
 import static android.os.Process.INVALID_UID;
 import static android.os.Process.VPN_UID;
 import static android.system.OsConstants.ETH_P_ALL;
@@ -115,6 +117,9 @@
 import static com.android.net.module.util.PermissionUtils.hasAnyPermissionOf;
 import static com.android.server.ConnectivityStatsLog.CONNECTIVITY_STATE_SAMPLE;
 import static com.android.server.connectivity.ConnectivityFlags.REQUEST_RESTRICTED_WIFI;
+import static com.android.server.connectivity.ConnectivityFlags.INGRESS_TO_VPN_ADDRESS_FILTERING;
+
+import static java.util.Map.Entry;
 
 import android.Manifest;
 import android.annotation.CheckResult;
@@ -214,7 +219,6 @@
 import android.net.Uri;
 import android.net.VpnManager;
 import android.net.VpnTransportInfo;
-import android.net.connectivity.ConnectivityCompatChanges;
 import android.net.metrics.IpConnectivityLog;
 import android.net.metrics.NetworkEvent;
 import android.net.netd.aidl.NativeUidRangeConfig;
@@ -474,6 +478,7 @@
     private volatile boolean mLockdownEnabled;
 
     private final boolean mRequestRestrictedWifiEnabled;
+    private final boolean mBackgroundFirewallChainEnabled;
 
     /**
      * Stale copy of uid blocked reasons provided by NPMS. As long as they are accessed only in
@@ -844,11 +849,6 @@
     private static final int EVENT_UID_FROZEN_STATE_CHANGED = 61;
 
     /**
-     * Event to inform the ConnectivityService handler when a uid has lost carrier privileges.
-     */
-    private static final int EVENT_UID_CARRIER_PRIVILEGES_LOST = 62;
-
-    /**
      * Argument for {@link #EVENT_PROVISIONING_NOTIFICATION} to indicate that the notification
      * should be shown.
      */
@@ -1001,6 +1001,9 @@
     // Uids that ConnectivityService is pending to close sockets of.
     private final Set<Integer> mPendingFrozenUids = new ArraySet<>();
 
+    // Flag to drop packets to VPN addresses ingressing via non-VPN interfaces.
+    private final boolean mIngressToVpnAddressFiltering;
+
     /**
      * Implements support for the legacy "one network per network type" model.
      *
@@ -1288,14 +1291,6 @@
     }
     private final LegacyTypeTracker mLegacyTypeTracker = new LegacyTypeTracker(this);
 
-    @VisibleForTesting
-    void onCarrierPrivilegesLost(Integer uid, Integer subId) {
-        if (mRequestRestrictedWifiEnabled) {
-            mHandler.sendMessage(mHandler.obtainMessage(
-                    EVENT_UID_CARRIER_PRIVILEGES_LOST, uid, subId));
-        }
-    }
-
     final LocalPriorityDump mPriorityDumper = new LocalPriorityDump();
     /**
      * Helper class which parses out priority arguments and dumps sections according to their
@@ -1517,10 +1512,11 @@
                 @NonNull final Context context,
                 @NonNull final TelephonyManager tm,
                 boolean requestRestrictedWifiEnabled,
-                @NonNull BiConsumer<Integer, Integer> listener) {
+                @NonNull BiConsumer<Integer, Integer> listener,
+                @NonNull final Handler connectivityServiceHandler) {
             if (isAtLeastT()) {
-                return new CarrierPrivilegeAuthenticator(
-                        context, tm, requestRestrictedWifiEnabled, listener);
+                return new CarrierPrivilegeAuthenticator(context, tm, requestRestrictedWifiEnabled,
+                        listener, connectivityServiceHandler);
             } else {
                 return null;
             }
@@ -1803,9 +1799,11 @@
         mLocationPermissionChecker = mDeps.makeLocationPermissionChecker(mContext);
         mRequestRestrictedWifiEnabled = mDeps.isAtLeastU()
                 && mDeps.isFeatureEnabled(context, REQUEST_RESTRICTED_WIFI);
+        mBackgroundFirewallChainEnabled = mDeps.isAtLeastV() && mDeps.isFeatureNotChickenedOut(
+                context, ConnectivityFlags.BACKGROUND_FIREWALL_CHAIN);
         mCarrierPrivilegeAuthenticator = mDeps.makeCarrierPrivilegeAuthenticator(
                 mContext, mTelephonyManager, mRequestRestrictedWifiEnabled,
-                this::onCarrierPrivilegesLost);
+                this::handleUidCarrierPrivilegesLost, mHandler);
 
         if (mDeps.isAtLeastU()
                 && mDeps
@@ -1978,10 +1976,8 @@
             activityManager.registerUidFrozenStateChangedCallback(
                     (Runnable r) -> r.run(), frozenStateChangedCallback);
         }
-
-        if (mDeps.isFeatureNotChickenedOut(mContext, LOG_BPF_RC)) {
-            mHandler.post(BpfLoaderRcUtils::checkBpfLoaderRc);
-        }
+        mIngressToVpnAddressFiltering = mDeps.isAtLeastT()
+                && mDeps.isFeatureNotChickenedOut(mContext, INGRESS_TO_VPN_ADDRESS_FILTERING);
     }
 
     /**
@@ -2242,7 +2238,11 @@
         final long ident = Binder.clearCallingIdentity();
         try {
             final boolean metered = nc == null ? true : nc.isMetered();
-            return mPolicyManager.isUidNetworkingBlocked(uid, metered);
+            if (mDeps.isAtLeastV()) {
+                return mBpfNetMaps.isUidNetworkingBlocked(uid, metered);
+            } else {
+                return mPolicyManager.isUidNetworkingBlocked(uid, metered);
+            }
         } finally {
             Binder.restoreCallingIdentity(ident);
         }
@@ -2611,7 +2611,7 @@
                 // Not the system, so it's an app requesting on its own behalf.
                 type = RequestType.RT_APP.getNumber();
             }
-            countPerType.put(type, countPerType.get(type, 0));
+            countPerType.put(type, countPerType.get(type, 0) + 1);
         }
         for (int i = countPerType.size() - 1; i >= 0; --i) {
             final RequestCountForType.Builder r = RequestCountForType.newBuilder();
@@ -2769,6 +2769,7 @@
 
     private boolean canSeeAllowedUids(final int pid, final int uid, final int netOwnerUid) {
         return Process.SYSTEM_UID == uid
+                || netOwnerUid == uid
                 || hasAnyPermissionOf(mContext, pid, uid,
                         android.Manifest.permission.NETWORK_FACTORY);
     }
@@ -2796,7 +2797,6 @@
         }
         if (!canSeeAllowedUids(callerPid, callerUid, newNc.getOwnerUid())) {
             newNc.setAllowedUids(new ArraySet<>());
-            newNc.setSubscriptionIds(Collections.emptySet());
         }
         redactUnderlyingNetworksForCapabilities(newNc, callerPid, callerUid);
 
@@ -3024,6 +3024,23 @@
         }
     }
 
+    private void maybeDisableLocalNetworkMatching(NetworkCapabilities nc, int callingUid) {
+        if (mDeps.isChangeEnabled(ENABLE_MATCH_LOCAL_NETWORK, callingUid)) {
+            return;
+        }
+        // If NET_CAPABILITY_LOCAL_NETWORK is not added to capability, request should not be
+        // satisfied by local networks.
+        if (!nc.hasCapability(NET_CAPABILITY_LOCAL_NETWORK)) {
+            nc.addForbiddenCapability(NET_CAPABILITY_LOCAL_NETWORK);
+        }
+    }
+
+    private void restrictRequestNetworkCapabilitiesForCaller(NetworkCapabilities nc,
+            int callingUid, String callerPackageName) {
+        restrictRequestUidsForCallerAndSetRequestorInfo(nc, callingUid, callerPackageName);
+        maybeDisableLocalNetworkMatching(nc, callingUid);
+    }
+
     @Override
     public @RestrictBackgroundStatus int getRestrictBackgroundStatusByCaller() {
         enforceAccessPermission();
@@ -3436,8 +3453,6 @@
     public static final String ALLOW_SYSUI_CONNECTIVITY_REPORTS =
             "allow_sysui_connectivity_reports";
 
-    public static final String LOG_BPF_RC = "log_bpf_rc_force_disable";
-
     public static final String ALLOW_SATALLITE_NETWORK_FALLBACK =
             "allow_satallite_network_fallback";
 
@@ -3811,6 +3826,10 @@
             mSatelliteAccessController.start();
         }
 
+        if (mCarrierPrivilegeAuthenticator != null) {
+            mCarrierPrivilegeAuthenticator.start();
+        }
+
         // On T+ devices, register callback for statsd to pull NETWORK_BPF_MAP_INFO atom
         if (mDeps.isAtLeastT()) {
             mBpfNetMaps.setPullAtomCallback(mContext);
@@ -4136,6 +4155,9 @@
         pw.println();
         pw.println("Multicast routing supported: " +
                 (mMulticastRoutingCoordinatorService != null));
+
+        pw.println();
+        pw.println("Background firewall chain enabled: " + mBackgroundFirewallChainEnabled);
     }
 
     private void dumpNetworks(IndentingPrintWriter pw) {
@@ -5332,6 +5354,7 @@
         // was is being disconnected the callbacks have already been sent, and if it is being
         // destroyed pending replacement they will be sent when it is disconnected.
         maybeDisableForwardRulesForDisconnectingNai(nai, false /* sendCallbacks */);
+        updateIngressToVpnAddressFiltering(null, nai.linkProperties, nai);
         try {
             mNetd.networkDestroy(nai.network.getNetId());
         } catch (RemoteException | ServiceSpecificException e) {
@@ -6009,7 +6032,15 @@
             if (nm == null) return;
 
             if (request == CaptivePortal.APP_REQUEST_REEVALUATION_REQUIRED) {
-                enforceNetworkStackPermission(mContext);
+                // This enforceNetworkStackPermission() should be adopted to check
+                // the required permission but this may be break OEM captive portal
+                // apps. Simply ignore the request if the caller does not have
+                // permission.
+                if (!hasNetworkStackPermission()) {
+                    Log.e(TAG, "Calling appRequest() without proper permission. Skip");
+                    return;
+                }
+
                 nm.forceReevaluation(mDeps.getCallingUid());
             }
         }
@@ -6490,9 +6521,6 @@
                     UidFrozenStateChangedArgs args = (UidFrozenStateChangedArgs) msg.obj;
                     handleFrozenUids(args.mUids, args.mFrozenStates);
                     break;
-                case EVENT_UID_CARRIER_PRIVILEGES_LOST:
-                    handleUidCarrierPrivilegesLost(msg.arg1, msg.arg2);
-                    break;
             }
         }
     }
@@ -7572,15 +7600,6 @@
                     "Insufficient permissions to request a specific signal strength");
         }
         mAppOpsManager.checkPackage(callerUid, callerPackageName);
-
-        if (nc.getSubscriptionIds().isEmpty()) {
-            return;
-        }
-        if (mRequestRestrictedWifiEnabled
-                && canRequestRestrictedNetworkDueToCarrierPrivileges(nc, callerUid)) {
-            return;
-        }
-        enforceNetworkFactoryPermission();
     }
 
     private int[] getSignalStrengthThresholds(@NonNull final NetworkAgentInfo nai) {
@@ -7714,10 +7733,12 @@
                 //  the state of the app when the request is filed, but we never change the
                 //  request if the app changes network state. http://b/29964605
                 enforceMeteredApnPolicy(networkCapabilities);
+                maybeDisableLocalNetworkMatching(networkCapabilities, callingUid);
                 break;
             case LISTEN_FOR_BEST:
                 enforceAccessPermission();
                 networkCapabilities = new NetworkCapabilities(networkCapabilities);
+                maybeDisableLocalNetworkMatching(networkCapabilities, callingUid);
                 break;
             default:
                 throw new IllegalArgumentException("Unsupported request type " + reqType);
@@ -7804,7 +7825,7 @@
         final UserHandle user = UserHandle.getUserHandleForUid(callingUid);
         // Only run the check if the change is enabled.
         if (!mDeps.isChangeEnabled(
-                ConnectivityCompatChanges.ENABLE_SELF_CERTIFIED_CAPABILITIES_DECLARATION,
+                ENABLE_SELF_CERTIFIED_CAPABILITIES_DECLARATION,
                 callingPackageName, user)) {
             return false;
         }
@@ -7956,8 +7977,8 @@
         ensureRequestableCapabilities(networkCapabilities);
         ensureSufficientPermissionsForRequest(networkCapabilities,
                 Binder.getCallingPid(), callingUid, callingPackageName);
-        restrictRequestUidsForCallerAndSetRequestorInfo(networkCapabilities,
-                callingUid, callingPackageName);
+        restrictRequestNetworkCapabilitiesForCaller(
+                networkCapabilities, callingUid, callingPackageName);
 
         NetworkRequest networkRequest = new NetworkRequest(networkCapabilities, TYPE_NONE,
                 nextNetworkRequestId(), NetworkRequest.Type.REQUEST);
@@ -8017,7 +8038,7 @@
         NetworkCapabilities nc = new NetworkCapabilities(networkCapabilities);
         ensureSufficientPermissionsForRequest(networkCapabilities,
                 Binder.getCallingPid(), callingUid, callingPackageName);
-        restrictRequestUidsForCallerAndSetRequestorInfo(nc, callingUid, callingPackageName);
+        restrictRequestNetworkCapabilitiesForCaller(nc, callingUid, callingPackageName);
         // Apps without the CHANGE_NETWORK_STATE permission can't use background networks, so
         // make all their listens include NET_CAPABILITY_FOREGROUND. That way, they will get
         // onLost and onAvailable callbacks when networks move in and out of the background.
@@ -8050,7 +8071,7 @@
         ensureSufficientPermissionsForRequest(networkCapabilities,
                 Binder.getCallingPid(), callingUid, callingPackageName);
         final NetworkCapabilities nc = new NetworkCapabilities(networkCapabilities);
-        restrictRequestUidsForCallerAndSetRequestorInfo(nc, callingUid, callingPackageName);
+        restrictRequestNetworkCapabilitiesForCaller(nc, callingUid, callingPackageName);
 
         NetworkRequest networkRequest = new NetworkRequest(nc, TYPE_NONE, nextNetworkRequestId(),
                 NetworkRequest.Type.LISTEN);
@@ -8681,6 +8702,8 @@
         // new interface (the interface name -> index map becomes initialized)
         updateVpnFiltering(newLp, oldLp, networkAgent);
 
+        updateIngressToVpnAddressFiltering(newLp, oldLp, networkAgent);
+
         updateMtu(newLp, oldLp);
         // TODO - figure out what to do for clat
 //        for (LinkProperties lp : newLp.getStackedLinks()) {
@@ -8972,6 +8995,87 @@
         }
     }
 
+    /**
+     * Returns ingress discard rules to drop packets to VPN addresses ingressing via non-VPN
+     * interfaces.
+     * Ingress discard rule is added to the address iff
+     *   1. The address is not a link local address
+     *   2. The address is used by a single VPN interface and not used by any other
+     *      interfaces even non-VPN ones
+     * This method can be called during network disconnects, when nai has already been removed from
+     * mNetworkAgentInfos.
+     *
+     * @param nai This method generates rules assuming lp of this nai is the lp at the second
+     *            argument.
+     * @param lp  This method generates rules assuming lp of nai at the first argument is this lp.
+     *            Caller passes old lp to generate old rules and new lp to generate new rules.
+     * @return    ingress discard rules. Set of pairs of addresses and interface names
+     */
+    private Set<Pair<InetAddress, String>> generateIngressDiscardRules(
+            @NonNull final NetworkAgentInfo nai, @Nullable final LinkProperties lp) {
+        Set<NetworkAgentInfo> nais = new ArraySet<>(mNetworkAgentInfos);
+        nais.add(nai);
+        // Determine how many networks each IP address is currently configured on.
+        // Ingress rules are added only for IP addresses that are configured on single interface.
+        final Map<InetAddress, Integer> addressOwnerCounts = new ArrayMap<>();
+        for (final NetworkAgentInfo agent : nais) {
+            if (agent.isDestroyed()) {
+                continue;
+            }
+            final LinkProperties agentLp = (nai == agent) ? lp : agent.linkProperties;
+            if (agentLp == null) {
+                continue;
+            }
+            for (final InetAddress addr: agentLp.getAllAddresses()) {
+                addressOwnerCounts.put(addr, addressOwnerCounts.getOrDefault(addr, 0) + 1);
+            }
+        }
+
+        // Iterates all networks instead of only generating rule for nai that was passed in since
+        // lp of the nai change could cause/resolve address collision and result in affecting rule
+        // for different network.
+        final Set<Pair<InetAddress, String>> ingressDiscardRules = new ArraySet<>();
+        for (final NetworkAgentInfo agent : nais) {
+            if (!agent.isVPN() || agent.isDestroyed()) {
+                continue;
+            }
+            final LinkProperties agentLp = (nai == agent) ? lp : agent.linkProperties;
+            if (agentLp == null || agentLp.getInterfaceName() == null) {
+                continue;
+            }
+
+            for (final InetAddress addr: agentLp.getAllAddresses()) {
+                if (addressOwnerCounts.get(addr) == 1 && !addr.isLinkLocalAddress()) {
+                    ingressDiscardRules.add(new Pair<>(addr, agentLp.getInterfaceName()));
+                }
+            }
+        }
+        return ingressDiscardRules;
+    }
+
+    private void updateIngressToVpnAddressFiltering(@Nullable LinkProperties newLp,
+            @Nullable LinkProperties oldLp, @NonNull NetworkAgentInfo nai) {
+        // Having isAtleastT to avoid NewApi linter error (b/303382209)
+        if (!mIngressToVpnAddressFiltering || !mDeps.isAtLeastT()) {
+            return;
+        }
+        final CompareOrUpdateResult<InetAddress, Pair<InetAddress, String>> ruleDiff =
+                new CompareOrUpdateResult<>(
+                        generateIngressDiscardRules(nai, oldLp),
+                        generateIngressDiscardRules(nai, newLp),
+                        (rule) -> rule.first);
+        for (Pair<InetAddress, String> rule: ruleDiff.removed) {
+            mBpfNetMaps.removeIngressDiscardRule(rule.first);
+        }
+        for (Pair<InetAddress, String> rule: ruleDiff.added) {
+            mBpfNetMaps.setIngressDiscardRule(rule.first, rule.second);
+        }
+        // setIngressDiscardRule overrides the existing rule
+        for (Pair<InetAddress, String> rule: ruleDiff.updated) {
+            mBpfNetMaps.setIngressDiscardRule(rule.first, rule.second);
+        }
+    }
+
     private void updateWakeOnLan(@NonNull LinkProperties lp) {
         if (mWolSupportedInterfaces == null) {
             mWolSupportedInterfaces = new ArraySet<>(mResources.get().getStringArray(
@@ -9155,6 +9259,9 @@
     }
 
     private void handleUidCarrierPrivilegesLost(int uid, int subId) {
+        if (!mRequestRestrictedWifiEnabled) {
+            return;
+        }
         ensureRunningOnConnectivityServiceThread();
         // A NetworkRequest needs to be revoked when all the conditions are met
         //   1. It requests restricted network
@@ -9162,7 +9269,7 @@
         //   3. The app doesn't have Carrier Privileges
         //   4. The app doesn't have permission.CONNECTIVITY_USE_RESTRICTED_NETWORKS
         for (final NetworkRequest nr : mNetworkRequests.keySet()) {
-            if ((nr.isRequest() || nr.isListen())
+            if (nr.isRequest()
                     && !nr.hasCapability(NET_CAPABILITY_NOT_RESTRICTED)
                     && nr.getRequestorUid() == uid
                     && getSubscriptionIdFromNetworkCaps(nr.networkCapabilities) == subId
@@ -12023,7 +12130,7 @@
         // This NetworkCapabilities is only used for matching to Networks. Clear out its owner uid
         // and administrator uids to be safe.
         final NetworkCapabilities nc = new NetworkCapabilities(request.networkCapabilities);
-        restrictRequestUidsForCallerAndSetRequestorInfo(nc, callingUid, callingPackageName);
+        restrictRequestNetworkCapabilitiesForCaller(nc, callingUid, callingPackageName);
 
         final NetworkRequest requestWithId =
                 new NetworkRequest(
@@ -13420,6 +13527,12 @@
     public void setUidFirewallRule(final int chain, final int uid, final int rule) {
         enforceNetworkStackOrSettingsPermission();
 
+        if (chain == FIREWALL_CHAIN_BACKGROUND && !mBackgroundFirewallChainEnabled) {
+            Log.i(TAG, "Ignoring operation setUidFirewallRule on the background chain because the"
+                    + " feature is disabled.");
+            return;
+        }
+
         // There are only two type of firewall rule: FIREWALL_RULE_ALLOW or FIREWALL_RULE_DENY
         int firewallRule = getFirewallRuleType(chain, rule);
 
@@ -13492,6 +13605,12 @@
     public void setFirewallChainEnabled(final int chain, final boolean enable) {
         enforceNetworkStackOrSettingsPermission();
 
+        if (chain == FIREWALL_CHAIN_BACKGROUND && !mBackgroundFirewallChainEnabled) {
+            Log.i(TAG, "Ignoring operation setFirewallChainEnabled on the background chain because"
+                    + " the feature is disabled.");
+            return;
+        }
+
         try {
             mBpfNetMaps.setChildChain(chain, enable);
         } catch (ServiceSpecificException e) {
@@ -13518,6 +13637,12 @@
     public void replaceFirewallChain(final int chain, final int[] uids) {
         enforceNetworkStackOrSettingsPermission();
 
+        if (chain == FIREWALL_CHAIN_BACKGROUND && !mBackgroundFirewallChainEnabled) {
+            Log.i(TAG, "Ignoring operation replaceFirewallChain on the background chain because"
+                    + " the feature is disabled.");
+            return;
+        }
+
         mBpfNetMaps.replaceUidChain(chain, uids);
     }
 
diff --git a/service/src/com/android/server/connectivity/CarrierPrivilegeAuthenticator.java b/service/src/com/android/server/connectivity/CarrierPrivilegeAuthenticator.java
index 04d0fc1..f5fa4fb 100644
--- a/service/src/com/android/server/connectivity/CarrierPrivilegeAuthenticator.java
+++ b/service/src/com/android/server/connectivity/CarrierPrivilegeAuthenticator.java
@@ -91,42 +91,62 @@
             @NonNull final TelephonyManager t,
             @NonNull final TelephonyManagerShim telephonyManagerShim,
             final boolean requestRestrictedWifiEnabled,
-            @NonNull BiConsumer<Integer, Integer> listener) {
+            @NonNull BiConsumer<Integer, Integer> listener,
+            @NonNull final Handler connectivityServiceHandler) {
         mContext = c;
         mTelephonyManager = t;
         mTelephonyManagerShim = telephonyManagerShim;
-        final HandlerThread thread = deps.makeHandlerThread();
-        thread.start();
-        mHandler = new Handler(thread.getLooper());
         mUseCallbacksForServiceChanged = deps.isFeatureEnabled(
                 c, CARRIER_SERVICE_CHANGED_USE_CALLBACK);
         mRequestRestrictedWifiEnabled = requestRestrictedWifiEnabled;
         mListener = listener;
+        if (mRequestRestrictedWifiEnabled) {
+            mHandler = connectivityServiceHandler;
+        } else {
+            final HandlerThread thread = deps.makeHandlerThread();
+            thread.start();
+            mHandler = new Handler(thread.getLooper());
+            synchronized (mLock) {
+                registerSimConfigChangedReceiver();
+                simConfigChanged();
+            }
+        }
+    }
+
+    private void registerSimConfigChangedReceiver() {
         final IntentFilter filter = new IntentFilter();
         filter.addAction(TelephonyManager.ACTION_MULTI_SIM_CONFIG_CHANGED);
-        synchronized (mLock) {
-            // Never unregistered because the system server never stops
-            c.registerReceiver(new BroadcastReceiver() {
-                @Override
-                public void onReceive(final Context context, final Intent intent) {
-                    switch (intent.getAction()) {
-                        case TelephonyManager.ACTION_MULTI_SIM_CONFIG_CHANGED:
-                            simConfigChanged();
-                            break;
-                        default:
-                            Log.d(TAG, "Unknown intent received, action: " + intent.getAction());
-                    }
+        // Never unregistered because the system server never stops
+        mContext.registerReceiver(new BroadcastReceiver() {
+            @Override
+            public void onReceive(final Context context, final Intent intent) {
+                switch (intent.getAction()) {
+                    case TelephonyManager.ACTION_MULTI_SIM_CONFIG_CHANGED:
+                        simConfigChanged();
+                        break;
+                    default:
+                        Log.d(TAG, "Unknown intent received, action: " + intent.getAction());
                 }
-            }, filter, null, mHandler);
-            simConfigChanged();
+            }
+        }, filter, null, mHandler);
+    }
+
+    /**
+     * Start CarrierPrivilegeAuthenticator
+     */
+    public void start() {
+        if (mRequestRestrictedWifiEnabled) {
+            registerSimConfigChangedReceiver();
+            mHandler.post(this::simConfigChanged);
         }
     }
 
     public CarrierPrivilegeAuthenticator(@NonNull final Context c,
             @NonNull final TelephonyManager t, final boolean requestRestrictedWifiEnabled,
-            @NonNull BiConsumer<Integer, Integer> listener) {
+            @NonNull BiConsumer<Integer, Integer> listener,
+            @NonNull final Handler connectivityServiceHandler) {
         this(c, new Dependencies(), t, TelephonyManagerShimImpl.newInstance(t),
-                requestRestrictedWifiEnabled, listener);
+                requestRestrictedWifiEnabled, listener, connectivityServiceHandler);
     }
 
     public static class Dependencies {
@@ -146,6 +166,10 @@
     }
 
     private void simConfigChanged() {
+        //  If mRequestRestrictedWifiEnabled is false, constructor calls simConfigChanged
+        if (mRequestRestrictedWifiEnabled) {
+            ensureRunningOnHandlerThread();
+        }
         synchronized (mLock) {
             unregisterCarrierPrivilegesListeners();
             mModemCount = mTelephonyManager.getActiveModemCount();
@@ -188,6 +212,7 @@
         public void onCarrierPrivilegesChanged(
                 @NonNull List<String> privilegedPackageNames,
                 @NonNull int[] privilegedUids) {
+            ensureRunningOnHandlerThread();
             if (mUseCallbacksForServiceChanged) return;
             // Re-trigger the synchronous check (which is also very cheap due
             // to caching in CarrierPrivilegesTracker). This allows consistency
@@ -198,6 +223,7 @@
         @Override
         public void onCarrierServiceChanged(@Nullable final String carrierServicePackageName,
                 final int carrierServiceUid) {
+            ensureRunningOnHandlerThread();
             if (!mUseCallbacksForServiceChanged) {
                 // Re-trigger the synchronous check (which is also very cheap due
                 // to caching in CarrierPrivilegesTracker). This allows consistency
@@ -439,6 +465,13 @@
         }
     }
 
+    private void ensureRunningOnHandlerThread() {
+        if (mHandler.getLooper().getThread() != Thread.currentThread()) {
+            throw new IllegalStateException(
+                    "Not running on handler thread: " + Thread.currentThread().getName());
+        }
+    }
+
     public void dump(IndentingPrintWriter pw) {
         pw.println("CarrierPrivilegeAuthenticator:");
         pw.println("mRequestRestrictedWifiEnabled = " + mRequestRestrictedWifiEnabled);
diff --git a/service/src/com/android/server/connectivity/ClatCoordinator.java b/service/src/com/android/server/connectivity/ClatCoordinator.java
index daaf91d..eea16bf 100644
--- a/service/src/com/android/server/connectivity/ClatCoordinator.java
+++ b/service/src/com/android/server/connectivity/ClatCoordinator.java
@@ -847,12 +847,12 @@
             if (mIngressMap.isEmpty()) {
                 pw.println("<empty>");
             }
-            pw.println("BPF ingress map: iif nat64Prefix v6Addr -> v4Addr oif");
+            pw.println("BPF ingress map: iif nat64Prefix v6Addr -> v4Addr oif (packets bytes)");
             pw.increaseIndent();
             mIngressMap.forEach((k, v) -> {
                 // TODO: print interface name
-                pw.println(String.format("%d %s/96 %s -> %s %d", k.iif, k.pfx96, k.local6,
-                        v.local4, v.oif));
+                pw.println(String.format("%d %s/96 %s -> %s %d (%d %d)", k.iif, k.pfx96, k.local6,
+                        v.local4, v.oif, v.packets, v.bytes));
             });
             pw.decreaseIndent();
         } catch (ErrnoException e) {
@@ -870,12 +870,13 @@
             if (mEgressMap.isEmpty()) {
                 pw.println("<empty>");
             }
-            pw.println("BPF egress map: iif v4Addr -> v6Addr nat64Prefix oif");
+            pw.println("BPF egress map: iif v4Addr -> v6Addr nat64Prefix oif (packets bytes)");
             pw.increaseIndent();
             mEgressMap.forEach((k, v) -> {
                 // TODO: print interface name
-                pw.println(String.format("%d %s -> %s %s/96 %d %s", k.iif, k.local4, v.local6,
-                        v.pfx96, v.oif, v.oifIsEthernet != 0 ? "ether" : "rawip"));
+                pw.println(String.format("%d %s -> %s %s/96 %d %s (%d %d)", k.iif, k.local4,
+                        v.local6, v.pfx96, v.oif, v.oifIsEthernet != 0 ? "ether" : "rawip",
+                        v.packets, v.bytes));
             });
             pw.decreaseIndent();
         } catch (ErrnoException e) {
diff --git a/service/src/com/android/server/connectivity/ConnectivityFlags.java b/service/src/com/android/server/connectivity/ConnectivityFlags.java
index bf09160..176307d 100644
--- a/service/src/com/android/server/connectivity/ConnectivityFlags.java
+++ b/service/src/com/android/server/connectivity/ConnectivityFlags.java
@@ -38,6 +38,12 @@
 
     public static final String REQUEST_RESTRICTED_WIFI =
             "request_restricted_wifi";
+
+    public static final String INGRESS_TO_VPN_ADDRESS_FILTERING =
+            "ingress_to_vpn_address_filtering";
+
+    public static final String BACKGROUND_FIREWALL_CHAIN = "background_firewall_chain";
+
     private boolean mNoRematchAllRequestsOnRegister;
 
     /**
diff --git a/service/src/com/android/server/connectivity/KeepaliveStatsTracker.java b/service/src/com/android/server/connectivity/KeepaliveStatsTracker.java
index 48af9fa..21dbb45 100644
--- a/service/src/com/android/server/connectivity/KeepaliveStatsTracker.java
+++ b/service/src/com/android/server/connectivity/KeepaliveStatsTracker.java
@@ -29,6 +29,7 @@
 import android.net.TelephonyNetworkSpecifier;
 import android.net.TransportInfo;
 import android.net.wifi.WifiInfo;
+import android.os.Build;
 import android.os.Handler;
 import android.os.SystemClock;
 import android.telephony.SubscriptionInfo;
@@ -39,6 +40,8 @@
 import android.util.SparseArray;
 import android.util.SparseIntArray;
 
+import androidx.annotation.RequiresApi;
+
 import com.android.internal.annotations.VisibleForTesting;
 import com.android.metrics.DailykeepaliveInfoReported;
 import com.android.metrics.DurationForNumOfKeepalive;
@@ -279,6 +282,7 @@
          *
          * @param dailyKeepaliveInfoReported the proto to write to statsD.
          */
+        @RequiresApi(Build.VERSION_CODES.TIRAMISU)
         public void writeStats(DailykeepaliveInfoReported dailyKeepaliveInfoReported) {
             ConnectivityStatsLog.write(
                     ConnectivityStatsLog.DAILY_KEEPALIVE_INFO_REPORTED,
diff --git a/service/src/com/android/server/connectivity/SatelliteAccessController.java b/service/src/com/android/server/connectivity/SatelliteAccessController.java
index b53abce..2cdc932 100644
--- a/service/src/com/android/server/connectivity/SatelliteAccessController.java
+++ b/service/src/com/android/server/connectivity/SatelliteAccessController.java
@@ -20,7 +20,10 @@
 import android.annotation.NonNull;
 import android.app.role.OnRoleHoldersChangedListener;
 import android.app.role.RoleManager;
+import android.content.BroadcastReceiver;
 import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
 import android.content.pm.ApplicationInfo;
 import android.content.pm.PackageManager;
 import android.os.Handler;
@@ -49,7 +52,6 @@
     private final Context mContext;
     private final Dependencies mDeps;
     private final DefaultMessageRoleListener mDefaultMessageRoleListener;
-    private final UserManager mUserManager;
     private final Consumer<Set<Integer>> mCallback;
     private final Handler mConnectivityServiceHandler;
 
@@ -114,7 +116,6 @@
             @NonNull final Handler connectivityServiceInternalHandler) {
         mContext = c;
         mDeps = deps;
-        mUserManager = mContext.getSystemService(UserManager.class);
         mDefaultMessageRoleListener = new DefaultMessageRoleListener();
         mCallback = callback;
         mConnectivityServiceHandler = connectivityServiceInternalHandler;
@@ -165,9 +166,6 @@
     }
 
     // on Role sms change triggered by OnRoleHoldersChangedListener()
-    // TODO(b/326373613): using UserLifecycleListener, callback to be received when user removed for
-    // user delete scenario. This to be used to update uid list and ML Layer request can also be
-    // updated.
     private void onRoleSmsChanged(@NonNull UserHandle userHandle) {
         int userId = userHandle.getIdentifier();
         if (userId == Process.INVALID_UID) {
@@ -184,9 +182,8 @@
                 mAllUsersSatelliteNetworkFallbackUidCache.get(userId, new ArraySet<>());
 
         Log.i(TAG, "currentUser : role_sms_packages: " + userId + " : " + packageNames);
-        final Set<Integer> newUidsForUser = !packageNames.isEmpty()
-                ? updateSatelliteNetworkFallbackUidListCache(packageNames, userHandle)
-                : new ArraySet<>();
+        final Set<Integer> newUidsForUser =
+                updateSatelliteNetworkFallbackUidListCache(packageNames, userHandle);
         Log.i(TAG, "satellite_fallback_uid: " + newUidsForUser);
 
         // on Role change, update the multilayer request at ConnectivityService with updated
@@ -197,6 +194,11 @@
 
         mAllUsersSatelliteNetworkFallbackUidCache.put(userId, newUidsForUser);
 
+        // Update all users fallback cache for user, send cs fallback to update ML request
+        reportSatelliteNetworkFallbackUids();
+    }
+
+    private void reportSatelliteNetworkFallbackUids() {
         // Merge all uids of multiple users available
         Set<Integer> mergedSatelliteNetworkFallbackUidCache = new ArraySet<>();
         for (int i = 0; i < mAllUsersSatelliteNetworkFallbackUidCache.size(); i++) {
@@ -210,27 +212,48 @@
         mCallback.accept(mergedSatelliteNetworkFallbackUidCache);
     }
 
-    private List<String> getRoleSmsChangedPackageName(UserHandle userHandle) {
-        try {
-            return mDeps.getRoleHoldersAsUser(RoleManager.ROLE_SMS, userHandle);
-        } catch (RuntimeException e) {
-            Log.wtf(TAG, "Could not get package name at role sms change update due to: " + e);
-            return null;
-        }
-    }
-
-    /** Register OnRoleHoldersChangedListener */
     public void start() {
         mConnectivityServiceHandler.post(this::updateAllUserRoleSmsUids);
+
+        // register sms OnRoleHoldersChangedListener
         mDefaultMessageRoleListener.register();
+
+        // Monitor for User removal intent, to update satellite fallback uids.
+        IntentFilter userRemovedFilter = new IntentFilter(Intent.ACTION_USER_REMOVED);
+        mContext.registerReceiver(new BroadcastReceiver() {
+            @Override
+            public void onReceive(Context context, Intent intent) {
+                final String action = intent.getAction();
+                if (Intent.ACTION_USER_REMOVED.equals(action)) {
+                    final UserHandle userHandle = intent.getParcelableExtra(Intent.EXTRA_USER);
+                    if (userHandle == null) return;
+                    updateSatelliteFallbackUidListOnUserRemoval(userHandle.getIdentifier());
+                } else {
+                    Log.wtf(TAG, "received unexpected intent: " + action);
+                }
+            }
+        }, userRemovedFilter, null, mConnectivityServiceHandler);
+
     }
 
     private void updateAllUserRoleSmsUids() {
-        List<UserHandle> existingUsers = mUserManager.getUserHandles(true /* excludeDying */);
+        UserManager userManager = mContext.getSystemService(UserManager.class);
+        // get existing user handles of available users
+        List<UserHandle> existingUsers = userManager.getUserHandles(true /*excludeDying*/);
+
         // Iterate through the user handles and obtain their uids with role sms and satellite
         // communication permission
+        Log.i(TAG, "existing users: " + existingUsers);
         for (UserHandle userHandle : existingUsers) {
             onRoleSmsChanged(userHandle);
         }
     }
+
+    private void updateSatelliteFallbackUidListOnUserRemoval(int userIdRemoved) {
+        Log.i(TAG, "user id removed:" + userIdRemoved);
+        if (mAllUsersSatelliteNetworkFallbackUidCache.contains(userIdRemoved)) {
+            mAllUsersSatelliteNetworkFallbackUidCache.remove(userIdRemoved);
+            reportSatelliteNetworkFallbackUids();
+        }
+    }
 }
diff --git a/staticlibs/Android.bp b/staticlibs/Android.bp
index 47e897d..ede6d3f 100644
--- a/staticlibs/Android.bp
+++ b/staticlibs/Android.bp
@@ -124,6 +124,8 @@
     ],
 }
 
+// The net-utils-device-common-bpf library requires the callers to contain
+// net-utils-device-common-struct-base.
 java_library {
     name: "net-utils-device-common-bpf",
     srcs: [
@@ -133,9 +135,7 @@
         "device/com/android/net/module/util/BpfUtils.java",
         "device/com/android/net/module/util/IBpfMap.java",
         "device/com/android/net/module/util/JniUtil.java",
-        "device/com/android/net/module/util/Struct.java",
         "device/com/android/net/module/util/TcUtils.java",
-        "framework/com/android/net/module/util/HexDump.java",
     ],
     sdk_version: "module_current",
     min_sdk_version: "30",
@@ -146,6 +146,7 @@
     libs: [
         "androidx.annotation_annotation",
         "framework-connectivity.stubs.module_lib",
+        "net-utils-device-common-struct-base",
     ],
     apex_available: [
         "com.android.tethering",
@@ -158,12 +159,9 @@
 }
 
 java_library {
-    name: "net-utils-device-common-struct",
+    name: "net-utils-device-common-struct-base",
     srcs: [
-        "device/com/android/net/module/util/Ipv6Utils.java",
-        "device/com/android/net/module/util/PacketBuilder.java",
         "device/com/android/net/module/util/Struct.java",
-        "device/com/android/net/module/util/structs/*.java",
     ],
     sdk_version: "module_current",
     min_sdk_version: "30",
@@ -176,6 +174,7 @@
     ],
     libs: [
         "androidx.annotation_annotation",
+        "framework-annotations-lib", // Required by InetAddressUtils.java
         "framework-connectivity.stubs.module_lib",
     ],
     apex_available: [
@@ -188,8 +187,39 @@
     },
 }
 
+// The net-utils-device-common-struct library requires the callers to contain
+// net-utils-device-common-struct-base.
+java_library {
+    name: "net-utils-device-common-struct",
+    srcs: [
+        "device/com/android/net/module/util/Ipv6Utils.java",
+        "device/com/android/net/module/util/PacketBuilder.java",
+        "device/com/android/net/module/util/structs/*.java",
+    ],
+    sdk_version: "module_current",
+    min_sdk_version: "30",
+    visibility: [
+        "//packages/modules/Connectivity:__subpackages__",
+        "//packages/modules/NetworkStack:__subpackages__",
+    ],
+    libs: [
+        "androidx.annotation_annotation",
+        "framework-annotations-lib", // Required by IpUtils.java
+        "framework-connectivity.stubs.module_lib",
+        "net-utils-device-common-struct-base",
+    ],
+    apex_available: [
+        "com.android.tethering",
+        "//apex_available:platform",
+    ],
+    lint: {
+        strict_updatability_linting: true,
+        error_checks: ["NewApi"],
+    },
+}
+
 // The net-utils-device-common-netlink library requires the callers to contain
-// net-utils-device-common-struct.
+// net-utils-device-common-struct and net-utils-device-common-struct-base.
 java_library {
     name: "net-utils-device-common-netlink",
     srcs: [
@@ -208,6 +238,7 @@
         // statically link here because callers of this library might already have a static
         // version linked.
         "net-utils-device-common-struct",
+        "net-utils-device-common-struct-base",
     ],
     apex_available: [
         "com.android.tethering",
@@ -220,7 +251,7 @@
 }
 
 // The net-utils-device-common-ip library requires the callers to contain
-// net-utils-device-common-struct.
+// net-utils-device-common-struct and net-utils-device-common-struct-base.
 java_library {
     // TODO : this target should probably be folded into net-utils-device-common
     name: "net-utils-device-common-ip",
@@ -272,13 +303,11 @@
         "//cts/tests/tests/net",
         "//cts/tests/tests/wifi",
         "//packages/modules/Connectivity/tests/cts/net",
-        "//frameworks/base/packages/Tethering",
         "//packages/modules/Connectivity/Tethering",
         "//frameworks/base/tests:__subpackages__",
         "//frameworks/opt/net/ike",
         "//frameworks/opt/telephony",
         "//frameworks/base/wifi:__subpackages__",
-        "//frameworks/base/packages/Connectivity:__subpackages__",
         "//packages/modules/Connectivity:__subpackages__",
         "//packages/modules/NetworkStack:__subpackages__",
         "//packages/modules/CaptivePortalLogin",
diff --git a/staticlibs/device/com/android/net/module/util/DeviceConfigUtils.java b/staticlibs/device/com/android/net/module/util/DeviceConfigUtils.java
index 5b7cbb8..0426ace 100644
--- a/staticlibs/device/com/android/net/module/util/DeviceConfigUtils.java
+++ b/staticlibs/device/com/android/net/module/util/DeviceConfigUtils.java
@@ -17,6 +17,7 @@
 package com.android.net.module.util;
 
 import static android.content.pm.PackageManager.MATCH_SYSTEM_ONLY;
+import static android.provider.DeviceConfig.NAMESPACE_CAPTIVEPORTALLOGIN;
 import static android.provider.DeviceConfig.NAMESPACE_CONNECTIVITY;
 import static android.provider.DeviceConfig.NAMESPACE_TETHERING;
 
@@ -203,6 +204,29 @@
                 () -> getTetheringModuleVersion(context));
     }
 
+    /**
+     * Check whether or not one specific experimental feature for a particular namespace from
+     * {@link DeviceConfig} is enabled by comparing module package version
+     * with current version of property. If this property version is valid, the corresponding
+     * experimental feature would be enabled, otherwise disabled.
+     *
+     * This is useful to ensure that if a module install is rolled back, flags are not left fully
+     * rolled out on a version where they have not been well tested.
+     *
+     * If the feature is disabled by default and enabled by flag push, this method should be used.
+     * If the feature is enabled by default and disabled by flag push (kill switch),
+     * {@link #isCaptivePortalLoginFeatureNotChickenedOut(Context, String)} should be used.
+     *
+     * @param context The global context information about an app environment.
+     * @param name The name of the property to look up.
+     * @return true if this feature is enabled, or false if disabled.
+     */
+    public static boolean isCaptivePortalLoginFeatureEnabled(@NonNull Context context,
+            @NonNull String name) {
+        return isFeatureEnabled(NAMESPACE_CAPTIVEPORTALLOGIN, name, false /* defaultEnabled */,
+                () -> getPackageVersion(context));
+    }
+
     private static boolean isFeatureEnabled(@NonNull String namespace,
             String name, boolean defaultEnabled, Supplier<Long> packageVersionSupplier) {
         final int flagValue = getDeviceConfigPropertyInt(namespace, name, 0 /* default value */);
diff --git a/staticlibs/device/com/android/net/module/util/netlink/NdOption.java b/staticlibs/device/com/android/net/module/util/netlink/NdOption.java
index defc88a..4f58380 100644
--- a/staticlibs/device/com/android/net/module/util/netlink/NdOption.java
+++ b/staticlibs/device/com/android/net/module/util/netlink/NdOption.java
@@ -67,6 +67,9 @@
             case StructNdOptRdnss.TYPE:
                 return StructNdOptRdnss.parse(buf);
 
+            case StructNdOptPio.TYPE:
+                return StructNdOptPio.parse(buf);
+
             default:
                 int newPosition = Math.min(buf.limit(), buf.position() + length * 8);
                 buf.position(newPosition);
diff --git a/staticlibs/device/com/android/net/module/util/netlink/StructNdOptPio.java b/staticlibs/device/com/android/net/module/util/netlink/StructNdOptPio.java
new file mode 100644
index 0000000..65541eb
--- /dev/null
+++ b/staticlibs/device/com/android/net/module/util/netlink/StructNdOptPio.java
@@ -0,0 +1,120 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.net.module.util.netlink;
+
+import android.net.IpPrefix;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+
+import com.android.net.module.util.HexDump;
+import com.android.net.module.util.Struct;
+import com.android.net.module.util.structs.PrefixInformationOption;
+
+import java.nio.BufferUnderflowException;
+import java.nio.ByteBuffer;
+import java.util.Objects;
+
+/**
+ * The Prefix Information Option. RFC 4861.
+ *
+ * 0                   1                   2                   3
+ * 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+ * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ * |     Type      |    Length     | Prefix Length |L|A|R|P| Rsvd1 |
+ * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ * |                         Valid Lifetime                        |
+ * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ * |                       Preferred Lifetime                      |
+ * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ * |                           Reserved2                           |
+ * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ * |                                                               |
+ * +                                                               +
+ * |                                                               |
+ * +                            Prefix                             +
+ * |                                                               |
+ * +                                                               +
+ * |                                                               |
+ * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ */
+public class StructNdOptPio extends NdOption {
+    private static final String TAG = StructNdOptPio.class.getSimpleName();
+    public static final int TYPE = 3;
+    public static final byte LENGTH = 4; // Length in 8-byte units
+
+    public final byte flags;
+    public final long preferred;
+    public final long valid;
+    @NonNull
+    public final IpPrefix prefix;
+
+    public StructNdOptPio(byte flags, long preferred, long valid, @NonNull final IpPrefix prefix) {
+        super((byte) TYPE, LENGTH);
+        this.prefix = Objects.requireNonNull(prefix, "prefix must not be null");
+        this.flags = flags;
+        this.preferred = preferred;
+        this.valid = valid;
+    }
+
+    /**
+     * Parses a PrefixInformation option from a {@link ByteBuffer}.
+     *
+     * @param buf The buffer from which to parse the option. The buffer's byte order must be
+     *            {@link java.nio.ByteOrder#BIG_ENDIAN}.
+     * @return the parsed option, or {@code null} if the option could not be parsed successfully.
+     */
+    public static StructNdOptPio parse(@NonNull ByteBuffer buf) {
+        if (buf == null || buf.remaining() < LENGTH * 8) return null;
+        try {
+            final PrefixInformationOption pio = Struct.parse(PrefixInformationOption.class, buf);
+            if (pio.type != TYPE) {
+                throw new IllegalArgumentException("Invalid type " + pio.type);
+            }
+            if (pio.length != LENGTH) {
+                throw new IllegalArgumentException("Invalid length " + pio.length);
+            }
+            return new StructNdOptPio(pio.flags, pio.preferredLifetime, pio.validLifetime,
+                    pio.getIpPrefix());
+        } catch (IllegalArgumentException | BufferUnderflowException e) {
+            // Not great, but better than throwing an exception that might crash the caller.
+            // Convention in this package is that null indicates that the option was truncated
+            // or malformed, so callers must already handle it.
+            Log.d(TAG, "Invalid PIO option: " + e);
+            return null;
+        }
+    }
+
+    protected void writeToByteBuffer(ByteBuffer buf) {
+        buf.put(PrefixInformationOption.build(prefix, flags, valid, preferred));
+    }
+
+    /** Outputs the wire format of the option to a new big-endian ByteBuffer. */
+    public ByteBuffer toByteBuffer() {
+        final ByteBuffer buf = ByteBuffer.allocate(Struct.getSize(PrefixInformationOption.class));
+        writeToByteBuffer(buf);
+        buf.flip();
+        return buf;
+    }
+
+    @Override
+    @NonNull
+    public String toString() {
+        return String.format("NdOptPio(flags:%s, preferred lft:%s, valid lft:%s, prefix:%s)",
+                HexDump.toHexString(flags), preferred, valid, prefix);
+    }
+}
diff --git a/staticlibs/device/com/android/net/module/util/structs/FragmentHeader.java b/staticlibs/device/com/android/net/module/util/structs/FragmentHeader.java
new file mode 100644
index 0000000..3da6a38
--- /dev/null
+++ b/staticlibs/device/com/android/net/module/util/structs/FragmentHeader.java
@@ -0,0 +1,56 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.net.module.util.structs;
+
+import com.android.net.module.util.Struct;
+import com.android.net.module.util.Struct.Field;
+import com.android.net.module.util.Struct.Type;
+
+/**
+ * IPv6 Fragment Extension header, as per https://tools.ietf.org/html/rfc2460.
+ *
+ * 0                   1                   2                   3
+ * 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+ * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ * |  Next Header  |   Reserved    |      Fragment Offset    |Res|M|
+ * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ * |                         Identification                        |
+ * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ */
+public class FragmentHeader extends Struct {
+    @Field(order = 0, type = Type.U8)
+    public final short nextHeader;
+    @Field(order = 1, type = Type.S8)
+    public final byte reserved;
+    @Field(order = 2, type = Type.U16)
+    public final int fragmentOffset;
+    @Field(order = 3, type = Type.S32)
+    public final int identification;
+
+    public FragmentHeader(final short nextHeader, final byte reserved, final int fragmentOffset,
+            final int identification) {
+        this.nextHeader = nextHeader;
+        this.reserved = reserved;
+        this.fragmentOffset = fragmentOffset;
+        this.identification = identification;
+    }
+
+    public FragmentHeader(final short nextHeader, final int fragmentOffset,
+            final int identification) {
+        this(nextHeader, (byte) 0, fragmentOffset, identification);
+    }
+}
diff --git a/staticlibs/device/com/android/net/module/util/structs/PrefixInformationOption.java b/staticlibs/device/com/android/net/module/util/structs/PrefixInformationOption.java
index 49d7654..bbbe571 100644
--- a/staticlibs/device/com/android/net/module/util/structs/PrefixInformationOption.java
+++ b/staticlibs/device/com/android/net/module/util/structs/PrefixInformationOption.java
@@ -21,11 +21,16 @@
 import android.net.IpPrefix;
 
 import androidx.annotation.NonNull;
+import androidx.annotation.VisibleForTesting;
 
 import com.android.net.module.util.Struct;
+import com.android.net.module.util.Struct.Computed;
 import com.android.net.module.util.Struct.Field;
 import com.android.net.module.util.Struct.Type;
 
+import java.net.Inet6Address;
+import java.net.InetAddress;
+import java.net.UnknownHostException;
 import java.nio.ByteBuffer;
 import java.nio.ByteOrder;
 
@@ -71,7 +76,11 @@
     @Field(order = 7, type = Type.ByteArray, arraysize = 16)
     public final byte[] prefix;
 
-    PrefixInformationOption(final byte type, final byte length, final byte prefixLen,
+    @Computed
+    private final IpPrefix mIpPrefix;
+
+    @VisibleForTesting
+    public PrefixInformationOption(final byte type, final byte length, final byte prefixLen,
             final byte flags, final long validLifetime, final long preferredLifetime,
             final int reserved, @NonNull final byte[] prefix) {
         this.type = type;
@@ -82,6 +91,23 @@
         this.preferredLifetime = preferredLifetime;
         this.reserved = reserved;
         this.prefix = prefix;
+
+        try {
+            final Inet6Address addr = (Inet6Address) InetAddress.getByAddress(prefix);
+            mIpPrefix = new IpPrefix(addr, prefixLen);
+        } catch (UnknownHostException | ClassCastException e) {
+            // UnknownHostException should never happen unless prefix is null.
+            // ClassCastException can occur when prefix is an IPv6 mapped IPv4 address.
+            // Both scenarios should throw an exception in the context of Struct#parse().
+            throw new IllegalArgumentException(e);
+        }
+    }
+
+    /**
+     * Return the prefix {@link IpPrefix} included in the PIO.
+     */
+    public IpPrefix getIpPrefix() {
+        return mIpPrefix;
     }
 
     /**
diff --git a/staticlibs/framework/com/android/net/module/util/NetworkCapabilitiesUtils.java b/staticlibs/framework/com/android/net/module/util/NetworkCapabilitiesUtils.java
index 54ce01e..7066131 100644
--- a/staticlibs/framework/com/android/net/module/util/NetworkCapabilitiesUtils.java
+++ b/staticlibs/framework/com/android/net/module/util/NetworkCapabilitiesUtils.java
@@ -39,12 +39,13 @@
 import static android.net.NetworkCapabilities.TRANSPORT_BLUETOOTH;
 import static android.net.NetworkCapabilities.TRANSPORT_CELLULAR;
 import static android.net.NetworkCapabilities.TRANSPORT_ETHERNET;
+import static android.net.NetworkCapabilities.TRANSPORT_SATELLITE;
 import static android.net.NetworkCapabilities.TRANSPORT_USB;
 import static android.net.NetworkCapabilities.TRANSPORT_VPN;
 import static android.net.NetworkCapabilities.TRANSPORT_WIFI;
 import static android.net.NetworkCapabilities.TRANSPORT_WIFI_AWARE;
 
-import static com.android.net.module.util.BitUtils.packBitList;
+import static com.android.net.module.util.BitUtils.packBits;
 import static com.android.net.module.util.BitUtils.unpackBits;
 
 import android.annotation.NonNull;
@@ -75,8 +76,8 @@
         TRANSPORT_BLUETOOTH,
         TRANSPORT_WIFI,
         TRANSPORT_ETHERNET,
-        TRANSPORT_USB
-
+        TRANSPORT_USB,
+        TRANSPORT_SATELLITE
         // Notably, TRANSPORT_TEST is not in this list as any network that has TRANSPORT_TEST and
         // one of the above transports should be counted as that transport, to keep tests as
         // realistic as possible.
@@ -88,41 +89,41 @@
       * and {@code FORCE_RESTRICTED_CAPABILITIES}.
      */
     @VisibleForTesting
-    public static final long RESTRICTED_CAPABILITIES = packBitList(
-            NET_CAPABILITY_BIP,
-            NET_CAPABILITY_CBS,
-            NET_CAPABILITY_DUN,
-            NET_CAPABILITY_EIMS,
-            NET_CAPABILITY_ENTERPRISE,
-            NET_CAPABILITY_FOTA,
-            NET_CAPABILITY_IA,
-            NET_CAPABILITY_IMS,
-            NET_CAPABILITY_MCX,
-            NET_CAPABILITY_RCS,
-            NET_CAPABILITY_VEHICLE_INTERNAL,
-            NET_CAPABILITY_VSIM,
-            NET_CAPABILITY_XCAP,
-            NET_CAPABILITY_MMTEL);
+    public static final long RESTRICTED_CAPABILITIES =
+            (1L << NET_CAPABILITY_BIP) |
+            (1L << NET_CAPABILITY_CBS) |
+            (1L << NET_CAPABILITY_DUN) |
+            (1L << NET_CAPABILITY_EIMS) |
+            (1L << NET_CAPABILITY_ENTERPRISE) |
+            (1L << NET_CAPABILITY_FOTA) |
+            (1L << NET_CAPABILITY_IA) |
+            (1L << NET_CAPABILITY_IMS) |
+            (1L << NET_CAPABILITY_MCX) |
+            (1L << NET_CAPABILITY_RCS) |
+            (1L << NET_CAPABILITY_VEHICLE_INTERNAL) |
+            (1L << NET_CAPABILITY_VSIM) |
+            (1L << NET_CAPABILITY_XCAP) |
+            (1L << NET_CAPABILITY_MMTEL);
 
     /**
      * Capabilities that force network to be restricted.
      * See {@code NetworkCapabilities#maybeMarkCapabilitiesRestricted}.
      */
-    private static final long FORCE_RESTRICTED_CAPABILITIES = packBitList(
-            NET_CAPABILITY_ENTERPRISE,
-            NET_CAPABILITY_OEM_PAID,
-            NET_CAPABILITY_OEM_PRIVATE);
+    private static final long FORCE_RESTRICTED_CAPABILITIES =
+            (1L << NET_CAPABILITY_ENTERPRISE) |
+            (1L << NET_CAPABILITY_OEM_PAID) |
+            (1L << NET_CAPABILITY_OEM_PRIVATE);
 
     /**
      * Capabilities that suggest that a network is unrestricted.
      * See {@code NetworkCapabilities#maybeMarkCapabilitiesRestricted}.
      */
     @VisibleForTesting
-    public static final long UNRESTRICTED_CAPABILITIES = packBitList(
-            NET_CAPABILITY_INTERNET,
-            NET_CAPABILITY_MMS,
-            NET_CAPABILITY_SUPL,
-            NET_CAPABILITY_WIFI_P2P);
+    public static final long UNRESTRICTED_CAPABILITIES =
+            (1L << NET_CAPABILITY_INTERNET) |
+            (1L << NET_CAPABILITY_MMS) |
+            (1L << NET_CAPABILITY_SUPL) |
+            (1L << NET_CAPABILITY_WIFI_P2P);
 
     /**
      * Get a transport that can be used to classify a network when displaying its info to users.
@@ -158,28 +159,33 @@
      *
      * @return {@code true} if the network should be restricted.
      */
-    // TODO: Use packBits(nc.getCapabilities()) to check more easily using bit masks.
     public static boolean inferRestrictedCapability(NetworkCapabilities nc) {
+        return inferRestrictedCapability(packBits(nc.getCapabilities()));
+    }
+
+    /**
+     * Infers that all the capabilities it provides are typically provided by restricted networks
+     * or not.
+     *
+     * @param capabilities see {@link NetworkCapabilities#getCapabilities()}
+     *
+     * @return {@code true} if the network should be restricted.
+     */
+    public static boolean inferRestrictedCapability(long capabilities) {
         // Check if we have any capability that forces the network to be restricted.
-        for (int capability : unpackBits(FORCE_RESTRICTED_CAPABILITIES)) {
-            if (nc.hasCapability(capability)) {
-                return true;
-            }
+        if ((capabilities & FORCE_RESTRICTED_CAPABILITIES) != 0) {
+            return true;
         }
 
         // Verify there aren't any unrestricted capabilities.  If there are we say
         // the whole thing is unrestricted unless it is forced to be restricted.
-        for (int capability : unpackBits(UNRESTRICTED_CAPABILITIES)) {
-            if (nc.hasCapability(capability)) {
-                return false;
-            }
+        if ((capabilities & UNRESTRICTED_CAPABILITIES) != 0) {
+            return false;
         }
 
         // Must have at least some restricted capabilities.
-        for (int capability : unpackBits(RESTRICTED_CAPABILITIES)) {
-            if (nc.hasCapability(capability)) {
-                return true;
-            }
+        if ((capabilities & RESTRICTED_CAPABILITIES) != 0) {
+            return true;
         }
         return false;
     }
diff --git a/staticlibs/native/bpf_headers/include/bpf/bpf_helpers.h b/staticlibs/native/bpf_headers/include/bpf/bpf_helpers.h
index baff09b..dc7925e 100644
--- a/staticlibs/native/bpf_headers/include/bpf/bpf_helpers.h
+++ b/staticlibs/native/bpf_headers/include/bpf/bpf_helpers.h
@@ -37,7 +37,26 @@
 #define BPFLOADER_IGNORED_ON_VERSION 33u
 
 // Android U / 14 (api level 34) - various new program types added
-#define BPFLOADER_U_VERSION 37u
+#define BPFLOADER_U_VERSION 38u
+
+// Android V / 15 (api level 35) - platform only
+// (note: the platform bpfloader in V isn't really versioned at all,
+//  as there is no need as it can only load objects compiled at the
+//  same time as itself and the rest of the platform)
+#define BPFLOADER_PLATFORM_VERSION 41u
+
+// Android Mainline - this bpfloader should eventually go back to T (or even S)
+// Note: this value (and the following +1u's) are hardcoded in NetBpfLoad.cpp
+#define BPFLOADER_MAINLINE_VERSION 42u
+
+// Android Mainline BpfLoader when running on Android T
+#define BPFLOADER_MAINLINE_T_VERSION (BPFLOADER_MAINLINE_VERSION + 1u)
+
+// Android Mainline BpfLoader when running on Android U
+#define BPFLOADER_MAINLINE_U_VERSION (BPFLOADER_MAINLINE_T_VERSION + 1u)
+
+// Android Mainline BpfLoader when running on Android V
+#define BPFLOADER_MAINLINE_V_VERSION (BPFLOADER_MAINLINE_U_VERSION + 1u)
 
 /* For mainline module use, you can #define BPFLOADER_{MIN/MAX}_VER
  * before #include "bpf_helpers.h" to change which bpfloaders will
@@ -48,7 +67,7 @@
  * In which case it's just best to use the default.
  */
 #ifndef BPFLOADER_MIN_VER
-#define BPFLOADER_MIN_VER COMPILE_FOR_BPFLOADER_VERSION
+#define BPFLOADER_MIN_VER BPFLOADER_PLATFORM_VERSION
 #endif
 
 #ifndef BPFLOADER_MAX_VER
@@ -111,10 +130,12 @@
 #define KVER_NONE KVER_(0)
 #define KVER_4_14 KVER(4, 14, 0)
 #define KVER_4_19 KVER(4, 19, 0)
-#define KVER_5_4 KVER(5, 4, 0)
-#define KVER_5_8 KVER(5, 8, 0)
-#define KVER_5_9 KVER(5, 9, 0)
+#define KVER_5_4  KVER(5, 4, 0)
+#define KVER_5_8  KVER(5, 8, 0)
+#define KVER_5_9  KVER(5, 9, 0)
 #define KVER_5_15 KVER(5, 15, 0)
+#define KVER_6_1  KVER(6, 1, 0)
+#define KVER_6_6  KVER(6, 6, 0)
 #define KVER_INF KVER_(0xFFFFFFFFu)
 
 #define KVER_IS_AT_LEAST(kver, a, b, c) ((kver).kver >= KVER(a, b, c).kver)
diff --git a/staticlibs/native/bpf_headers/include/bpf/bpf_map_def.h b/staticlibs/native/bpf_headers/include/bpf/bpf_map_def.h
index ef03c4d..00ef91a 100644
--- a/staticlibs/native/bpf_headers/include/bpf/bpf_map_def.h
+++ b/staticlibs/native/bpf_headers/include/bpf/bpf_map_def.h
@@ -48,9 +48,6 @@
 #define DEFAULT_SIZEOF_BPF_MAP_DEF 32       // v0.0 struct: enum (uint sized) + 7 uint
 #define DEFAULT_SIZEOF_BPF_PROG_DEF 20      // v0.0 struct: 4 uint + bool + 3 byte alignment pad
 
-// By default, unless otherwise specified, allow the use of features only supported by v0.37.
-#define COMPILE_FOR_BPFLOADER_VERSION 37u
-
 /*
  * The bpf_{map,prog}_def structures are compiled for different architectures.
  * Once by the BPF compiler for the BPF architecture, and once by a C++
diff --git a/staticlibs/tests/unit/Android.bp b/staticlibs/tests/unit/Android.bp
index 4c226cc..fa466f8 100644
--- a/staticlibs/tests/unit/Android.bp
+++ b/staticlibs/tests/unit/Android.bp
@@ -25,6 +25,7 @@
         "net-utils-device-common-async",
         "net-utils-device-common-bpf",
         "net-utils-device-common-ip",
+        "net-utils-device-common-struct-base",
         "net-utils-device-common-wear",
     ],
     libs: [
diff --git a/staticlibs/tests/unit/src/com/android/net/module/util/DeviceConfigUtilsTest.java b/staticlibs/tests/unit/src/com/android/net/module/util/DeviceConfigUtilsTest.java
index f32337d..9fb61d9 100644
--- a/staticlibs/tests/unit/src/com/android/net/module/util/DeviceConfigUtilsTest.java
+++ b/staticlibs/tests/unit/src/com/android/net/module/util/DeviceConfigUtilsTest.java
@@ -17,6 +17,7 @@
 package com.android.net.module.util;
 
 import static android.content.pm.PackageManager.MATCH_SYSTEM_ONLY;
+import static android.provider.DeviceConfig.NAMESPACE_CAPTIVEPORTALLOGIN;
 import static android.provider.DeviceConfig.NAMESPACE_CONNECTIVITY;
 import static android.provider.DeviceConfig.NAMESPACE_TETHERING;
 
@@ -233,8 +234,12 @@
                 TEST_EXPERIMENT_FLAG));
         doReturn(TEST_FLAG_VALUE_STRING).when(() -> DeviceConfig.getProperty(NAMESPACE_TETHERING,
                 TEST_EXPERIMENT_FLAG));
+        doReturn(TEST_FLAG_VALUE_STRING).when(() -> DeviceConfig.getProperty(
+                NAMESPACE_CAPTIVEPORTALLOGIN, TEST_EXPERIMENT_FLAG));
         assertTrue(DeviceConfigUtils.isNetworkStackFeatureEnabled(mContext, TEST_EXPERIMENT_FLAG));
         assertTrue(DeviceConfigUtils.isTetheringFeatureEnabled(mContext, TEST_EXPERIMENT_FLAG));
+        assertTrue(DeviceConfigUtils.isCaptivePortalLoginFeatureEnabled(mContext,
+                TEST_EXPERIMENT_FLAG));
     }
     @Test
     public void testIsFeatureEnabledFeatureDefaultDisabled() throws Exception {
@@ -242,8 +247,12 @@
                 TEST_EXPERIMENT_FLAG));
         doReturn(null).when(() -> DeviceConfig.getProperty(NAMESPACE_TETHERING,
                 TEST_EXPERIMENT_FLAG));
+        doReturn(null).when(() -> DeviceConfig.getProperty(NAMESPACE_CAPTIVEPORTALLOGIN,
+                TEST_EXPERIMENT_FLAG));
         assertFalse(DeviceConfigUtils.isNetworkStackFeatureEnabled(mContext, TEST_EXPERIMENT_FLAG));
         assertFalse(DeviceConfigUtils.isTetheringFeatureEnabled(mContext, TEST_EXPERIMENT_FLAG));
+        assertFalse(DeviceConfigUtils.isCaptivePortalLoginFeatureEnabled(mContext,
+                TEST_EXPERIMENT_FLAG));
 
         // If the flag is unset, package info is not queried
         verify(mContext, never()).getPackageManager();
@@ -257,8 +266,12 @@
                 TEST_EXPERIMENT_FLAG));
         doReturn("1").when(() -> DeviceConfig.getProperty(NAMESPACE_TETHERING,
                 TEST_EXPERIMENT_FLAG));
+        doReturn("1").when(() -> DeviceConfig.getProperty(NAMESPACE_CAPTIVEPORTALLOGIN,
+                TEST_EXPERIMENT_FLAG));
         assertTrue(DeviceConfigUtils.isNetworkStackFeatureEnabled(mContext, TEST_EXPERIMENT_FLAG));
         assertTrue(DeviceConfigUtils.isTetheringFeatureEnabled(mContext, TEST_EXPERIMENT_FLAG));
+        assertTrue(DeviceConfigUtils.isCaptivePortalLoginFeatureEnabled(mContext,
+                TEST_EXPERIMENT_FLAG));
 
         // If the feature is force enabled, package info is not queried
         verify(mContext, never()).getPackageManager();
@@ -272,8 +285,12 @@
                 TEST_EXPERIMENT_FLAG));
         doReturn("-1").when(() -> DeviceConfig.getProperty(NAMESPACE_TETHERING,
                 TEST_EXPERIMENT_FLAG));
+        doReturn("-1").when(() -> DeviceConfig.getProperty(NAMESPACE_CAPTIVEPORTALLOGIN,
+                TEST_EXPERIMENT_FLAG));
         assertFalse(DeviceConfigUtils.isNetworkStackFeatureEnabled(mContext, TEST_EXPERIMENT_FLAG));
         assertFalse(DeviceConfigUtils.isTetheringFeatureEnabled(mContext, TEST_EXPERIMENT_FLAG));
+        assertFalse(DeviceConfigUtils.isCaptivePortalLoginFeatureEnabled(mContext,
+                TEST_EXPERIMENT_FLAG));
 
         // If the feature is force disabled, package info is not queried
         verify(mContext, never()).getPackageManager();
@@ -290,24 +307,36 @@
                 TEST_EXPERIMENT_FLAG));
         doReturn("1").when(() -> DeviceConfig.getProperty(NAMESPACE_TETHERING,
                 TEST_EXPERIMENT_FLAG));
+        doReturn("1").when(() -> DeviceConfig.getProperty(NAMESPACE_CAPTIVEPORTALLOGIN,
+                TEST_EXPERIMENT_FLAG));
         assertTrue(DeviceConfigUtils.isNetworkStackFeatureEnabled(mContext, TEST_EXPERIMENT_FLAG));
         assertTrue(DeviceConfigUtils.isTetheringFeatureEnabled(mContext, TEST_EXPERIMENT_FLAG));
+        assertTrue(DeviceConfigUtils.isCaptivePortalLoginFeatureEnabled(mContext,
+                TEST_EXPERIMENT_FLAG));
 
         // Feature should be disabled by flag value "999999999".
         doReturn("999999999").when(() -> DeviceConfig.getProperty(NAMESPACE_CONNECTIVITY,
                 TEST_EXPERIMENT_FLAG));
         doReturn("999999999").when(() -> DeviceConfig.getProperty(NAMESPACE_TETHERING,
                 TEST_EXPERIMENT_FLAG));
+        doReturn("999999999").when(() -> DeviceConfig.getProperty(NAMESPACE_CAPTIVEPORTALLOGIN,
+                TEST_EXPERIMENT_FLAG));
         assertFalse(DeviceConfigUtils.isNetworkStackFeatureEnabled(mContext, TEST_EXPERIMENT_FLAG));
         assertFalse(DeviceConfigUtils.isTetheringFeatureEnabled(mContext, TEST_EXPERIMENT_FLAG));
+        assertFalse(DeviceConfigUtils.isCaptivePortalLoginFeatureEnabled(mContext,
+                TEST_EXPERIMENT_FLAG));
 
         // If the flag is not set feature is disabled
         doReturn(null).when(() -> DeviceConfig.getProperty(NAMESPACE_CONNECTIVITY,
                 TEST_EXPERIMENT_FLAG));
         doReturn(null).when(() -> DeviceConfig.getProperty(NAMESPACE_TETHERING,
                 TEST_EXPERIMENT_FLAG));
+        doReturn(null).when(() -> DeviceConfig.getProperty(NAMESPACE_CAPTIVEPORTALLOGIN,
+                TEST_EXPERIMENT_FLAG));
         assertFalse(DeviceConfigUtils.isNetworkStackFeatureEnabled(mContext, TEST_EXPERIMENT_FLAG));
         assertFalse(DeviceConfigUtils.isTetheringFeatureEnabled(mContext, TEST_EXPERIMENT_FLAG));
+        assertFalse(DeviceConfigUtils.isCaptivePortalLoginFeatureEnabled(mContext,
+                TEST_EXPERIMENT_FLAG));
     }
 
     @Test
@@ -320,9 +349,13 @@
                 NAMESPACE_CONNECTIVITY, TEST_EXPERIMENT_FLAG));
         doReturn("0").when(() -> DeviceConfig.getProperty(
                 NAMESPACE_TETHERING, TEST_EXPERIMENT_FLAG));
+        doReturn("0").when(() -> DeviceConfig.getProperty(
+                NAMESPACE_CAPTIVEPORTALLOGIN, TEST_EXPERIMENT_FLAG));
 
         assertFalse(DeviceConfigUtils.isNetworkStackFeatureEnabled(mContext, TEST_EXPERIMENT_FLAG));
         assertFalse(DeviceConfigUtils.isTetheringFeatureEnabled(mContext, TEST_EXPERIMENT_FLAG));
+        assertFalse(DeviceConfigUtils.isCaptivePortalLoginFeatureEnabled(mContext,
+                TEST_EXPERIMENT_FLAG));
 
         doReturn(TEST_FLAG_VALUE_STRING).when(() -> DeviceConfig.getProperty(NAMESPACE_TETHERING,
                 TEST_EXPERIMENT_FLAG));
@@ -343,6 +376,21 @@
     }
 
     @Test
+    public void testIsCaptivePortalLoginFeatureEnabledCaching() throws Exception {
+        doReturn(TEST_FLAG_VALUE_STRING).when(() -> DeviceConfig.getProperty(
+                NAMESPACE_CAPTIVEPORTALLOGIN, TEST_EXPERIMENT_FLAG));
+        assertTrue(DeviceConfigUtils.isCaptivePortalLoginFeatureEnabled(mContext,
+                TEST_EXPERIMENT_FLAG));
+        assertTrue(DeviceConfigUtils.isCaptivePortalLoginFeatureEnabled(mContext,
+                TEST_EXPERIMENT_FLAG));
+
+        // Package info is only queried once
+        verify(mContext, times(1)).getPackageManager();
+        verify(mContext, times(1)).getPackageName();
+        verify(mPm, times(1)).getPackageInfo(anyString(), anyInt());
+    }
+
+    @Test
     public void testIsTetheringFeatureEnabledCaching() throws Exception {
         doReturn(TEST_FLAG_VALUE_STRING).when(() -> DeviceConfig.getProperty(NAMESPACE_TETHERING,
                 TEST_EXPERIMENT_FLAG));
diff --git a/staticlibs/tests/unit/src/com/android/net/module/util/netlink/StructNdOptPioTest.java b/staticlibs/tests/unit/src/com/android/net/module/util/netlink/StructNdOptPioTest.java
new file mode 100644
index 0000000..0d88829
--- /dev/null
+++ b/staticlibs/tests/unit/src/com/android/net/module/util/netlink/StructNdOptPioTest.java
@@ -0,0 +1,205 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.net.module.util.netlink;
+
+import static com.android.net.module.util.NetworkStackConstants.INFINITE_LEASE;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+
+import android.net.IpPrefix;
+
+import androidx.test.filters.SmallTest;
+import androidx.test.runner.AndroidJUnit4;
+
+import com.android.net.module.util.structs.PrefixInformationOption;
+
+import libcore.util.HexEncoding;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+
+@RunWith(AndroidJUnit4.class)
+@SmallTest
+public class StructNdOptPioTest {
+    private static final IpPrefix TEST_PREFIX = new IpPrefix("2a00:79e1:abc:f605::/64");
+    private static final byte TEST_PIO_FLAGS_P_UNSET = (byte) 0xC0; // L=1,A=1
+    private static final byte TEST_PIO_FLAGS_P_SET   = (byte) 0xD0; // L=1,A=1,P=1
+    private static final String PIO_BYTES =
+            "0304"                                // type=3, length=4
+            + "40"                                // prefix length=64
+            + "C0"                                // L=1,A=1
+            + "00278D00"                          // valid=259200
+            + "00093A80"                          // preferred=604800
+            + "00000000"                          // Reserved2
+            + "2A0079E10ABCF6050000000000000000"; // prefix=2a00:79e1:abc:f605::
+
+    private static final String PIO_WITH_P_FLAG_BYTES =
+            "0304"                                // type=3, length=4
+            + "40"                                // prefix length=64
+            + "D0"                                // L=1,A=1,P=1
+            + "00278D00"                          // valid=2592000
+            + "00093A80"                          // preferred=604800
+            + "00000000"                          // Reserved2
+            + "2A0079E10ABCF6050000000000000000"; // prefix=2a00:79e1:abc:f605::
+
+    private static final String PIO_WITH_P_FLAG_INFINITY_LIFETIME_BYTES =
+            "0304"                                // type=3, length=4
+            + "40"                                // prefix length=64
+            + "D0"                                // L=1,A=1,P=1
+            + "FFFFFFFF"                          // valid=infinity
+            + "FFFFFFFF"                          // preferred=infintiy
+            + "00000000"                          // Reserved2
+            + "2A0079E10ABCF6050000000000000000"; // prefix=2a00:79e1:abc:f605::
+
+    private static void assertPioOptMatches(final StructNdOptPio opt, int length, byte flags,
+            long preferred, long valid, final IpPrefix prefix) {
+        assertEquals(StructNdOptPio.TYPE, opt.type);
+        assertEquals(length, opt.length);
+        assertEquals(flags, opt.flags);
+        assertEquals(preferred, opt.preferred);
+        assertEquals(valid, opt.valid);
+        assertEquals(prefix, opt.prefix);
+    }
+
+    private static void assertToByteBufferMatches(final StructNdOptPio opt, final String expected) {
+        String actual = HexEncoding.encodeToString(opt.toByteBuffer().array());
+        assertEquals(expected, actual);
+    }
+
+    private static void doPioParsingTest(final String optionHexString, int length, byte flags,
+            long preferred, long valid, final IpPrefix prefix) {
+        final byte[] rawBytes = HexEncoding.decode(optionHexString);
+        final StructNdOptPio opt = StructNdOptPio.parse(ByteBuffer.wrap(rawBytes));
+        assertPioOptMatches(opt, length, flags, preferred, valid, prefix);
+        assertToByteBufferMatches(opt, optionHexString);
+    }
+
+    @Test
+    public void testParsingPioWithoutPFlag() {
+        doPioParsingTest(PIO_BYTES, 4 /* length */, TEST_PIO_FLAGS_P_UNSET,
+                604800 /* preferred */, 2592000 /* valid */, TEST_PREFIX);
+    }
+
+    @Test
+    public void testParsingPioWithPFlag() {
+        doPioParsingTest(PIO_WITH_P_FLAG_BYTES, 4 /* length */, TEST_PIO_FLAGS_P_SET,
+                604800 /* preferred */, 2592000 /* valid */, TEST_PREFIX);
+    }
+
+    @Test
+    public void testParsingPioWithPFlag_infinityLifetime() {
+        doPioParsingTest(PIO_WITH_P_FLAG_INFINITY_LIFETIME_BYTES, 4 /* length */,
+                TEST_PIO_FLAGS_P_SET,
+                Integer.toUnsignedLong(INFINITE_LEASE) /* preferred */,
+                Integer.toUnsignedLong(INFINITE_LEASE) /* valid */,
+                TEST_PREFIX);
+    }
+
+    @Test
+    public void testToByteBuffer() {
+        final StructNdOptPio pio =
+                new StructNdOptPio(TEST_PIO_FLAGS_P_UNSET, 604800 /* preferred */,
+                        2592000 /* valid */, TEST_PREFIX);
+        assertToByteBufferMatches(pio, PIO_BYTES);
+    }
+
+    @Test
+    public void testToByteBuffer_withPFlag() {
+        final StructNdOptPio pio =
+                new StructNdOptPio(TEST_PIO_FLAGS_P_SET, 604800 /* preferred */,
+                        2592000 /* valid */, TEST_PREFIX);
+        assertToByteBufferMatches(pio, PIO_WITH_P_FLAG_BYTES);
+    }
+
+    @Test
+    public void testToByteBuffer_infinityLifetime() {
+        final StructNdOptPio pio =
+                new StructNdOptPio(TEST_PIO_FLAGS_P_SET,
+                        Integer.toUnsignedLong(INFINITE_LEASE) /* preferred */,
+                        Integer.toUnsignedLong(INFINITE_LEASE) /* valid */, TEST_PREFIX);
+        assertToByteBufferMatches(pio, PIO_WITH_P_FLAG_INFINITY_LIFETIME_BYTES);
+    }
+
+    private static ByteBuffer makePioOption(byte type, byte length, byte prefixLen, byte flags,
+            long valid, long preferred, final byte[] prefix) {
+        final PrefixInformationOption pio = new PrefixInformationOption(type, length, prefixLen,
+                flags, valid, preferred, 0 /* reserved */, prefix);
+        return ByteBuffer.wrap(pio.writeToBytes(ByteOrder.BIG_ENDIAN));
+    }
+
+    @Test
+    public void testParsing_invalidOptionType() {
+        final ByteBuffer buf = makePioOption((byte) 24 /* wrong type:RIO */,
+                (byte) 4 /* length */, (byte) 64 /* prefixLen */, TEST_PIO_FLAGS_P_SET,
+                2592000 /* valid */, 604800 /* preferred */, TEST_PREFIX.getRawAddress());
+        assertNull(StructNdOptPio.parse(buf));
+    }
+
+    @Test
+    public void testParsing_invalidOptionLength() {
+        final ByteBuffer buf = makePioOption((byte) 24 /* wrong type:RIO */,
+                (byte) 3 /* wrong length */, (byte) 64 /* prefixLen */,
+                TEST_PIO_FLAGS_P_SET, 2592000 /* valid */, 604800 /* preferred */,
+                TEST_PREFIX.getRawAddress());
+        assertNull(StructNdOptPio.parse(buf));
+    }
+
+    @Test
+    public void testParsing_truncatedByteBuffer() {
+        final ByteBuffer buf = makePioOption((byte) 3 /* type */, (byte) 4 /* length */,
+                (byte) 64 /* prefixLen */, TEST_PIO_FLAGS_P_SET,
+                2592000 /* valid */, 604800 /* preferred */, TEST_PREFIX.getRawAddress());
+        final int len = buf.limit();
+        for (int i = 0; i < buf.limit() - 1; i++) {
+            buf.flip();
+            buf.limit(i);
+            assertNull("Option truncated to " + i + " bytes, should have returned null",
+                    StructNdOptPio.parse(buf));
+        }
+        buf.flip();
+        buf.limit(len);
+
+        final StructNdOptPio opt = StructNdOptPio.parse(buf);
+        assertPioOptMatches(opt, (byte) 4 /* length */, TEST_PIO_FLAGS_P_SET,
+                604800 /* preferred */, 2592000 /* valid */, TEST_PREFIX);
+    }
+
+    @Test
+    public void testParsing_invalidByteBufferLength() {
+        final ByteBuffer buf = makePioOption((byte) 3 /* type */, (byte) 4 /* length */,
+                (byte) 64 /* prefixLen */, TEST_PIO_FLAGS_P_SET,
+                2592000 /* valid */, 604800 /* preferred */, TEST_PREFIX.getRawAddress());
+        buf.limit(31); // less than 4 * 8
+        assertNull(StructNdOptPio.parse(buf));
+    }
+
+    @Test
+    public void testToString() {
+        final ByteBuffer buf = makePioOption((byte) 3 /* type */, (byte) 4 /* length */,
+                (byte) 64 /* prefixLen */, TEST_PIO_FLAGS_P_SET,
+                2592000 /* valid */, 604800 /* preferred */, TEST_PREFIX.getRawAddress());
+        final StructNdOptPio opt = StructNdOptPio.parse(buf);
+        final String expected = "NdOptPio"
+                + "(flags:D0, preferred lft:604800, valid lft:2592000,"
+                + " prefix:2a00:79e1:abc:f605::/64)";
+        assertEquals(expected, opt.toString());
+    }
+}
diff --git a/staticlibs/tests/unit/src/com/android/net/module/util/structs/FragmentHeaderTest.java b/staticlibs/tests/unit/src/com/android/net/module/util/structs/FragmentHeaderTest.java
new file mode 100644
index 0000000..1a78ca5
--- /dev/null
+++ b/staticlibs/tests/unit/src/com/android/net/module/util/structs/FragmentHeaderTest.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.net.module.util.structs;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+
+import androidx.test.runner.AndroidJUnit4;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.Arrays;
+
+@RunWith(AndroidJUnit4.class)
+public class FragmentHeaderTest {
+    private static final byte[] HEADER_BYTES = new byte[] {
+        17, /* nextHeader  */
+        0, /* reserved */
+        15, 1, /* fragmentOffset */
+        1, 2, 3, 4 /* identification */
+    };
+
+    @Test
+    public void testConstructor() {
+        FragmentHeader fragHdr = new FragmentHeader((short) 10 /* nextHeader */,
+                (byte) 11 /* reserved */,
+                12 /* fragmentOffset */,
+                13 /* identification */);
+
+        assertEquals(10, fragHdr.nextHeader);
+        assertEquals(11, fragHdr.reserved);
+        assertEquals(12, fragHdr.fragmentOffset);
+        assertEquals(13, fragHdr.identification);
+    }
+
+    @Test
+    public void testParseFragmentHeader() {
+        final ByteBuffer buf = ByteBuffer.wrap(HEADER_BYTES);
+        buf.order(ByteOrder.BIG_ENDIAN);
+        FragmentHeader fragHdr = FragmentHeader.parse(FragmentHeader.class, buf);
+
+        assertEquals(17, fragHdr.nextHeader);
+        assertEquals(0, fragHdr.reserved);
+        assertEquals(0xF01, fragHdr.fragmentOffset);
+        assertEquals(0x1020304, fragHdr.identification);
+    }
+
+    @Test
+    public void testWriteToBytes() {
+        FragmentHeader fragHdr = new FragmentHeader((short) 17 /* nextHeader */,
+                (byte) 0 /* reserved */,
+                0xF01 /* fragmentOffset */,
+                0x1020304 /* identification */);
+
+        byte[] bytes = fragHdr.writeToBytes(ByteOrder.BIG_ENDIAN);
+
+        assertArrayEquals("bytes = " + Arrays.toString(bytes), HEADER_BYTES, bytes);
+    }
+}
diff --git a/staticlibs/testutils/Android.bp b/staticlibs/testutils/Android.bp
index a8e5a69..9124ac0 100644
--- a/staticlibs/testutils/Android.bp
+++ b/staticlibs/testutils/Android.bp
@@ -40,6 +40,7 @@
         "net-utils-device-common-async",
         "net-utils-device-common-netlink",
         "net-utils-device-common-struct",
+        "net-utils-device-common-struct-base",
         "net-utils-device-common-wear",
         "modules-utils-build_system",
     ],
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/AutoReleaseNetworkCallbackRule.kt b/staticlibs/testutils/devicetests/com/android/testutils/AutoReleaseNetworkCallbackRule.kt
new file mode 100644
index 0000000..28ae609
--- /dev/null
+++ b/staticlibs/testutils/devicetests/com/android/testutils/AutoReleaseNetworkCallbackRule.kt
@@ -0,0 +1,154 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.testutils
+
+import android.net.ConnectivityManager
+import android.net.ConnectivityManager.NetworkCallback
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.net.NetworkRequest
+import androidx.test.platform.app.InstrumentationRegistry
+import com.android.testutils.RecorderCallback.CallbackEntry
+import java.util.Collections
+import kotlin.test.fail
+import org.junit.rules.TestRule
+import org.junit.runner.Description
+import org.junit.runners.model.Statement
+
+/**
+ * A rule to file [NetworkCallback]s to request or watch networks.
+ *
+ * The callbacks filed in test methods are automatically unregistered when the method completes.
+ */
+class AutoReleaseNetworkCallbackRule : NetworkCallbackHelper(), TestRule {
+    override fun apply(base: Statement, description: Description): Statement {
+        return RequestCellNetworkStatement(base, description)
+    }
+
+    private inner class RequestCellNetworkStatement(
+        private val base: Statement,
+        private val description: Description
+    ) : Statement() {
+        override fun evaluate() {
+            tryTest {
+                base.evaluate()
+            } cleanup {
+                unregisterAll()
+            }
+        }
+    }
+}
+
+/**
+ * Helps file [NetworkCallback]s to request or watch networks, keeping track of them for cleanup.
+ */
+open class NetworkCallbackHelper {
+    private val cm by lazy {
+        InstrumentationRegistry.getInstrumentation().context
+            .getSystemService(ConnectivityManager::class.java)
+            ?: fail("ConnectivityManager not found")
+    }
+    private val cbToCleanup = Collections.synchronizedSet(mutableSetOf<NetworkCallback>())
+    private var cellRequestCb: TestableNetworkCallback? = null
+
+    /**
+     * Convenience method to request a cell network, similarly to [requestNetwork].
+     *
+     * The rule will keep tract of a single cell network request, which can be unrequested manually
+     * using [unrequestCell].
+     */
+    fun requestCell(): Network {
+        if (cellRequestCb != null) {
+            fail("Cell network was already requested")
+        }
+        val cb = requestNetwork(
+            NetworkRequest.Builder()
+                .addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
+                .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
+                .build()
+        )
+        cellRequestCb = cb
+        return cb.expect<CallbackEntry.Available>(
+            errorMsg = "Cell network not available. " +
+                    "Please ensure the device has working mobile data."
+        ).network
+    }
+
+    /**
+     * Unrequest a cell network requested through [requestCell].
+     */
+    fun unrequestCell() {
+        val cb = cellRequestCb ?: fail("Cell network was not requested")
+        unregisterNetworkCallback(cb)
+        cellRequestCb = null
+    }
+
+    /**
+     * File a request for a Network.
+     *
+     * This will fail tests (throw) if the cell network cannot be obtained, or if it was already
+     * requested.
+     *
+     * Tests may call [unregisterNetworkCallback] once they are done using the returned [Network],
+     * otherwise it will be automatically unrequested after the test.
+     */
+    @JvmOverloads
+    fun requestNetwork(
+        request: NetworkRequest,
+        cb: TestableNetworkCallback = TestableNetworkCallback()
+    ): TestableNetworkCallback {
+        cm.requestNetwork(request, cb)
+        cbToCleanup.add(cb)
+        return cb
+    }
+
+    /**
+     * File a callback for a NetworkRequest.
+     *
+     * This will fail tests (throw) if the cell network cannot be obtained, or if it was already
+     * requested.
+     *
+     * Tests may call [unregisterNetworkCallback] once they are done using the returned [Network],
+     * otherwise it will be automatically unrequested after the test.
+     */
+    @JvmOverloads
+    fun registerNetworkCallback(
+        request: NetworkRequest,
+        cb: TestableNetworkCallback = TestableNetworkCallback()
+    ): TestableNetworkCallback {
+        cm.registerNetworkCallback(request, cb)
+        cbToCleanup.add(cb)
+        return cb
+    }
+
+    /**
+     * Unregister a callback filed using registration methods in this class.
+     */
+    fun unregisterNetworkCallback(cb: NetworkCallback) {
+        cm.unregisterNetworkCallback(cb)
+        cbToCleanup.remove(cb)
+    }
+
+    /**
+     * Unregister all callbacks that were filed using registration methods in this class.
+     */
+    fun unregisterAll() {
+        cbToCleanup.forEach { cm.unregisterNetworkCallback(it) }
+        cbToCleanup.clear()
+        cellRequestCb = null
+    }
+}
diff --git a/staticlibs/testutils/hostdevice/com/android/testutils/ConcurrentUtils.kt b/staticlibs/testutils/hostdevice/com/android/testutils/ConcurrentUtils.kt
index af4f96d..c6e5f25 100644
--- a/staticlibs/testutils/hostdevice/com/android/testutils/ConcurrentUtils.kt
+++ b/staticlibs/testutils/hostdevice/com/android/testutils/ConcurrentUtils.kt
@@ -19,10 +19,77 @@
 package com.android.testutils
 
 import java.util.concurrent.CountDownLatch
+import java.util.concurrent.ExecutorService
 import java.util.concurrent.TimeUnit
+import java.util.function.Consumer
 import kotlin.system.measureTimeMillis
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
 
 // For Java usage
 fun durationOf(fn: Runnable) = measureTimeMillis { fn.run() }
 
 fun CountDownLatch.await(timeoutMs: Long): Boolean = await(timeoutMs, TimeUnit.MILLISECONDS)
+
+/**
+ * Quit resources provided as a list by a supplier.
+ *
+ * The supplier may return more resources as the process progresses, for example while interrupting
+ * threads and waiting for them to finish they may spawn more threads, so this implements a
+ * [maxRetryCount] which, in this case, would be the maximum length of the thread chain that can be
+ * terminated.
+ */
+fun <T> quitResources(
+    maxRetryCount: Int,
+    supplier: () -> List<T>,
+    terminator: Consumer<T>
+) {
+    // Run it multiple times since new threads might be generated in a thread
+    // that is about to be terminated
+    for (retryCount in 0 until maxRetryCount) {
+        val resourcesToBeCleared = supplier()
+        if (resourcesToBeCleared.isEmpty()) return
+        for (resource in resourcesToBeCleared) {
+            terminator.accept(resource)
+        }
+    }
+    assertEmpty(supplier())
+}
+
+/**
+ * Implementation of [quitResources] to interrupt and wait for [ExecutorService]s to finish.
+ */
+@JvmOverloads
+fun quitExecutorServices(
+    maxRetryCount: Int,
+    interrupt: Boolean = true,
+    timeoutMs: Long = 10_000L,
+    supplier: () -> List<ExecutorService>
+) {
+    quitResources(maxRetryCount, supplier) { ecs ->
+        if (interrupt) {
+            ecs.shutdownNow()
+        }
+        assertTrue(ecs.awaitTermination(timeoutMs, TimeUnit.MILLISECONDS),
+            "ExecutorServices did not terminate within timeout")
+    }
+}
+
+/**
+ * Implementation of [quitResources] to interrupt and wait for [Thread]s to finish.
+ */
+@JvmOverloads
+fun quitThreads(
+    maxRetryCount: Int,
+    interrupt: Boolean = true,
+    timeoutMs: Long = 10_000L,
+    supplier: () -> List<Thread>
+) {
+    quitResources(maxRetryCount, supplier) { th ->
+        if (interrupt) {
+            th.interrupt()
+        }
+        th.join(timeoutMs)
+        assertFalse(th.isAlive, "Threads did not terminate within timeout.")
+    }
+}
diff --git a/tests/common/java/android/net/NetworkCapabilitiesTest.java b/tests/common/java/android/net/NetworkCapabilitiesTest.java
index 3124b1b..6eb56c7b 100644
--- a/tests/common/java/android/net/NetworkCapabilitiesTest.java
+++ b/tests/common/java/android/net/NetworkCapabilitiesTest.java
@@ -61,7 +61,6 @@
 import static android.net.NetworkCapabilities.TRANSPORT_WIFI;
 import static android.net.NetworkCapabilities.TRANSPORT_WIFI_AWARE;
 import static android.os.Process.INVALID_UID;
-
 import static com.android.modules.utils.build.SdkLevel.isAtLeastS;
 import static com.android.modules.utils.build.SdkLevel.isAtLeastT;
 import static com.android.modules.utils.build.SdkLevel.isAtLeastV;
@@ -69,7 +68,6 @@
 import static com.android.testutils.MiscAsserts.assertEmpty;
 import static com.android.testutils.MiscAsserts.assertThrows;
 import static com.android.testutils.ParcelUtils.assertParcelingIsLossless;
-
 import static org.junit.Assert.assertArrayEquals;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
@@ -83,25 +81,22 @@
 import android.net.wifi.aware.PeerHandle;
 import android.net.wifi.aware.WifiAwareNetworkSpecifier;
 import android.os.Build;
-import android.test.suitebuilder.annotation.SmallTest;
 import android.util.ArraySet;
 import android.util.Range;
-
+import androidx.test.filters.SmallTest;
 import com.android.testutils.CompatUtil;
 import com.android.testutils.ConnectivityModuleTest;
 import com.android.testutils.DevSdkIgnoreRule;
 import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo;
 import com.android.testutils.DevSdkIgnoreRunner;
-
-import org.junit.Rule;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-import org.mockito.Mockito;
-
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
 import java.util.Set;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mockito;
 
 @SmallTest
 @RunWith(DevSdkIgnoreRunner.class)
diff --git a/tests/cts/OWNERS b/tests/cts/OWNERS
index cb4ca59..acf506d 100644
--- a/tests/cts/OWNERS
+++ b/tests/cts/OWNERS
@@ -9,4 +9,6 @@
 # For incremental changes on EthernetManagerTest to increase coverage for existing behavior and for
 # testing bug fixes.
 per-file net/src/android/net/cts/EthernetManagerTest.kt = prohr@google.com #{LAST_RESORT_SUGGESTION}
+# Temporary ownership to develop APF CTS tests.
+per-file net/src/android/net/cts/ApfIntegrationTest.kt = prohr@google.com #{LAST_RESORT_SUGGESTION}
 
diff --git a/tests/cts/hostside/Android.bp b/tests/cts/hostside/Android.bp
index 2253138..92e7cfb 100644
--- a/tests/cts/hostside/Android.bp
+++ b/tests/cts/hostside/Android.bp
@@ -18,10 +18,6 @@
 // downstream branches. The CtsHostsideNetworkTestsAppNext target will not exist in
 // some downstream branches, but it should exist in aosp and some downstream branches.
 
-
-
-
-
 package {
     default_team: "trendy_team_fwk_core_networking",
     default_applicable_licenses: ["Android-Apache-2.0"],
diff --git a/tests/cts/hostside/app/src/com/android/cts/net/hostside/VpnTest.java b/tests/cts/hostside/app/src/com/android/cts/net/hostside/VpnTest.java
index 454940f..0f86d78 100755
--- a/tests/cts/hostside/app/src/com/android/cts/net/hostside/VpnTest.java
+++ b/tests/cts/hostside/app/src/com/android/cts/net/hostside/VpnTest.java
@@ -23,6 +23,7 @@
 import static android.content.pm.PackageManager.FEATURE_TELEPHONY;
 import static android.content.pm.PackageManager.FEATURE_WIFI;
 import static android.net.ConnectivityManager.TYPE_VPN;
+import static android.net.NetworkCapabilities.TRANSPORT_TEST;
 import static android.net.NetworkCapabilities.TRANSPORT_VPN;
 import static android.os.Process.INVALID_UID;
 import static android.system.OsConstants.AF_INET;
@@ -72,6 +73,7 @@
 import android.database.Cursor;
 import android.net.ConnectivityManager;
 import android.net.ConnectivityManager.NetworkCallback;
+import android.net.InetAddresses;
 import android.net.IpSecManager;
 import android.net.LinkAddress;
 import android.net.LinkProperties;
@@ -91,6 +93,7 @@
 import android.net.cts.util.CtsNetUtils;
 import android.net.util.KeepaliveUtils;
 import android.net.wifi.WifiManager;
+import android.os.Binder;
 import android.os.Build;
 import android.os.Handler;
 import android.os.Looper;
@@ -120,6 +123,7 @@
 import com.android.net.module.util.ArrayTrackRecord;
 import com.android.net.module.util.CollectionUtils;
 import com.android.net.module.util.PacketBuilder;
+import com.android.testutils.AutoReleaseNetworkCallbackRule;
 import com.android.testutils.DevSdkIgnoreRule;
 import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo;
 import com.android.testutils.RecorderCallback;
@@ -230,9 +234,13 @@
     // The registered callbacks.
     private List<NetworkCallback> mRegisteredCallbacks = new ArrayList<>();
 
-    @Rule
+    @Rule(order = 1)
     public final DevSdkIgnoreRule mDevSdkIgnoreRule = new DevSdkIgnoreRule();
 
+    @Rule(order = 2)
+    public final AutoReleaseNetworkCallbackRule
+            mNetworkCallbackRule = new AutoReleaseNetworkCallbackRule();
+
     private boolean supportedHardware() {
         final PackageManager pm = getInstrumentation().getContext().getPackageManager();
         return !pm.hasSystemFeature("android.hardware.type.watch");
@@ -249,6 +257,7 @@
 
     @Before
     public void setUp() throws Exception {
+        assumeTrue(supportedHardware());
         mNetwork = null;
         mTestContext = getInstrumentation().getContext();
         mTargetContext = getInstrumentation().getTargetContext();
@@ -269,7 +278,6 @@
     public void tearDown() throws Exception {
         restorePrivateDnsSetting();
         mRemoteSocketFactoryClient.unbind();
-        mCtsNetUtils.tearDown();
         Log.i(TAG, "Stopping VPN");
         stopVpn();
         unregisterRegisteredCallbacks();
@@ -879,7 +887,6 @@
 
     @Test @IgnoreUpTo(SC_V2) // TODO: Use to Build.VERSION_CODES.SC_V2 when available
     public void testChangeUnderlyingNetworks() throws Exception {
-        assumeTrue(supportedHardware());
         assumeTrue(mPackageManager.hasSystemFeature(FEATURE_WIFI));
         assumeTrue(mPackageManager.hasSystemFeature(FEATURE_TELEPHONY));
         final TestableNetworkCallback callback = new TestableNetworkCallback();
@@ -887,9 +894,7 @@
         testAndCleanup(() -> {
             // Ensure both of wifi and mobile data are connected.
             final Network wifiNetwork = mCtsNetUtils.ensureWifiConnected();
-            assertTrue("Wifi is not connected", (wifiNetwork != null));
-            final Network cellNetwork = mCtsNetUtils.connectToCell();
-            assertTrue("Mobile data is not connected", (cellNetwork != null));
+            final Network cellNetwork = mNetworkCallbackRule.requestCell();
             // Store current default network.
             final Network defaultNetwork = mCM.getActiveNetwork();
             // Start VPN and set empty array as its underlying networks.
@@ -938,7 +943,6 @@
 
     @Test
     public void testDefault() throws Exception {
-        assumeTrue(supportedHardware());
         if (!SdkLevel.isAtLeastS() && (
                 SystemProperties.getInt("persist.adb.tcp.port", -1) > -1
                         || SystemProperties.getInt("service.adb.tcp.port", -1) > -1)) {
@@ -1017,8 +1021,8 @@
             // This needs to be done before testing  private DNS because checkStrictModePrivateDns
             // will set the private DNS server to a nonexistent name, which will cause validation to
             // fail and could cause the default network to switch (e.g., from wifi to cellular).
-            systemDefaultCallback.assertNoCallback();
-            otherUidCallback.assertNoCallback();
+            assertNoCallbackExceptCapOrLpChange(systemDefaultCallback);
+            assertNoCallbackExceptCapOrLpChange(otherUidCallback);
         }
 
         checkStrictModePrivateDns();
@@ -1026,10 +1030,13 @@
         receiver.unregisterQuietly();
     }
 
+    private void assertNoCallbackExceptCapOrLpChange(TestableNetworkCallback callback) {
+        callback.assertNoCallback(c -> !(c instanceof CallbackEntry.CapabilitiesChanged
+                || c instanceof CallbackEntry.LinkPropertiesChanged));
+    }
+
     @Test
     public void testAppAllowed() throws Exception {
-        assumeTrue(supportedHardware());
-
         FileDescriptor fd = openSocketFdInOtherApp(TEST_HOST, 80, TIMEOUT_MS);
 
         // Shell app must not be put in here or it would kill the ADB-over-network use case
@@ -1137,8 +1144,6 @@
     }
 
     private void doTestAutomaticOnOffKeepaliveMode(final boolean closeSocket) throws Exception {
-        assumeTrue(supportedHardware());
-
         // Get default network first before starting VPN
         final Network defaultNetwork = mCM.getActiveNetwork();
         final TestableNetworkCallback cb = new TestableNetworkCallback();
@@ -1226,8 +1231,6 @@
 
     @Test
     public void testAppDisallowed() throws Exception {
-        assumeTrue(supportedHardware());
-
         FileDescriptor localFd = openSocketFd(TEST_HOST, 80, TIMEOUT_MS);
         FileDescriptor remoteFd = openSocketFdInOtherApp(TEST_HOST, 80, TIMEOUT_MS);
 
@@ -1260,8 +1263,6 @@
 
     @Test
     public void testSocketClosed() throws Exception {
-        assumeTrue(supportedHardware());
-
         final FileDescriptor localFd = openSocketFd(TEST_HOST, 80, TIMEOUT_MS);
         final List<FileDescriptor> remoteFds = new ArrayList<>();
 
@@ -1285,7 +1286,6 @@
 
     @Test
     public void testExcludedRoutes() throws Exception {
-        assumeTrue(supportedHardware());
         assumeTrue(SdkLevel.isAtLeastT());
 
         // Shell app must not be put in here or it would kill the ADB-over-network use case
@@ -1306,8 +1306,6 @@
 
     @Test
     public void testIncludedRoutes() throws Exception {
-        assumeTrue(supportedHardware());
-
         // Shell app must not be put in here or it would kill the ADB-over-network use case
         String allowedApps = mRemoteSocketFactoryClient.getPackageName() + "," + mPackageName;
         startVpn(new String[]{"192.0.2.2/32", "2001:db8:1:2::ffe/128"} /* addresses */,
@@ -1325,7 +1323,6 @@
 
     @Test
     public void testInterleavedRoutes() throws Exception {
-        assumeTrue(supportedHardware());
         assumeTrue(SdkLevel.isAtLeastT());
 
         // Shell app must not be put in here or it would kill the ADB-over-network use case
@@ -1353,8 +1350,6 @@
 
     @Test
     public void testGetConnectionOwnerUidSecurity() throws Exception {
-        assumeTrue(supportedHardware());
-
         DatagramSocket s;
         InetAddress address = InetAddress.getByName("localhost");
         s = new DatagramSocket();
@@ -1375,7 +1370,6 @@
 
     @Test
     public void testSetProxy() throws  Exception {
-        assumeTrue(supportedHardware());
         ProxyInfo initialProxy = mCM.getDefaultProxy();
         // Receiver for the proxy change broadcast.
         BlockingBroadcastReceiver proxyBroadcastReceiver = new ProxyChangeBroadcastReceiver();
@@ -1415,7 +1409,6 @@
 
     @Test
     public void testSetProxyDisallowedApps() throws Exception {
-        assumeTrue(supportedHardware());
         ProxyInfo initialProxy = mCM.getDefaultProxy();
 
         String disallowedApps = mPackageName;
@@ -1441,7 +1434,6 @@
 
     @Test
     public void testNoProxy() throws Exception {
-        assumeTrue(supportedHardware());
         ProxyInfo initialProxy = mCM.getDefaultProxy();
         BlockingBroadcastReceiver proxyBroadcastReceiver = new ProxyChangeBroadcastReceiver();
         proxyBroadcastReceiver.register();
@@ -1476,7 +1468,6 @@
 
     @Test
     public void testBindToNetworkWithProxy() throws Exception {
-        assumeTrue(supportedHardware());
         String allowedApps = mPackageName;
         Network initialNetwork = mCM.getActiveNetwork();
         ProxyInfo initialProxy = mCM.getDefaultProxy();
@@ -1501,9 +1492,6 @@
 
     @Test
     public void testVpnMeterednessWithNoUnderlyingNetwork() throws Exception {
-        if (!supportedHardware()) {
-            return;
-        }
         // VPN is not routing any traffic i.e. its underlying networks is an empty array.
         ArrayList<Network> underlyingNetworks = new ArrayList<>();
         String allowedApps = mPackageName;
@@ -1533,9 +1521,6 @@
 
     @Test
     public void testVpnMeterednessWithNullUnderlyingNetwork() throws Exception {
-        if (!supportedHardware()) {
-            return;
-        }
         Network underlyingNetwork = mCM.getActiveNetwork();
         if (underlyingNetwork == null) {
             Log.i(TAG, "testVpnMeterednessWithNullUnderlyingNetwork cannot execute"
@@ -1562,9 +1547,6 @@
 
     @Test
     public void testVpnMeterednessWithNonNullUnderlyingNetwork() throws Exception {
-        if (!supportedHardware()) {
-            return;
-        }
         Network underlyingNetwork = mCM.getActiveNetwork();
         if (underlyingNetwork == null) {
             Log.i(TAG, "testVpnMeterednessWithNonNullUnderlyingNetwork cannot execute"
@@ -1604,9 +1586,6 @@
 
     @Test
     public void testAlwaysMeteredVpnWithNullUnderlyingNetwork() throws Exception {
-        if (!supportedHardware()) {
-            return;
-        }
         Network underlyingNetwork = mCM.getActiveNetwork();
         if (underlyingNetwork == null) {
             Log.i(TAG, "testAlwaysMeteredVpnWithNullUnderlyingNetwork cannot execute"
@@ -1631,9 +1610,6 @@
 
     @Test
     public void testAlwaysMeteredVpnWithNonNullUnderlyingNetwork() throws Exception {
-        if (!supportedHardware()) {
-            return;
-        }
         Network underlyingNetwork = mCM.getActiveNetwork();
         if (underlyingNetwork == null) {
             Log.i(TAG, "testAlwaysMeteredVpnWithNonNullUnderlyingNetwork cannot execute"
@@ -1671,9 +1647,6 @@
 
     @Test
     public void testB141603906() throws Exception {
-        if (!supportedHardware()) {
-            return;
-        }
         final InetSocketAddress src = new InetSocketAddress(0);
         final InetSocketAddress dst = new InetSocketAddress(0);
         final int NUM_THREADS = 8;
@@ -1781,8 +1754,6 @@
      */
     @Test
     public void testDownloadWithDownloadManagerDisallowed() throws Exception {
-        assumeTrue(supportedHardware());
-
         // Start a VPN with DownloadManager package in disallowed list.
         startVpn(new String[] {"192.0.2.2/32", "2001:db8:1:2::ffe/128"},
                 new String[] {"192.0.2.0/24", "2001:db8::/32"},
@@ -1838,7 +1809,6 @@
 
     @Test @IgnoreUpTo(Build.VERSION_CODES.R)
     public void testBlockIncomingPackets() throws Exception {
-        assumeTrue(supportedHardware());
         final Network network = mCM.getActiveNetwork();
         assertNotNull("Requires a working Internet connection", network);
 
@@ -1907,7 +1877,6 @@
 
     @Test
     public void testSetVpnDefaultForUids() throws Exception {
-        assumeTrue(supportedHardware());
         assumeTrue(SdkLevel.isAtLeastU());
 
         final Network defaultNetwork = mCM.getActiveNetwork();
@@ -1953,6 +1922,81 @@
             });
     }
 
+    /**
+     * Check if packets to a VPN interface's IP arriving on a non-VPN interface are dropped or not.
+     * If the test interface has a different address from the VPN interface, packets must be dropped
+     * If the test interface has the same address as the VPN interface, packets must not be
+     * dropped
+     *
+     * @param duplicatedAddress true to bring up the test interface with the same address as the VPN
+     *                          interface
+     */
+    private void doTestDropPacketToVpnAddress(final boolean duplicatedAddress)
+            throws Exception {
+        final NetworkRequest request = new NetworkRequest.Builder()
+                .removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
+                .removeCapability(NetworkCapabilities.NET_CAPABILITY_TRUSTED)
+                .addTransportType(TRANSPORT_TEST)
+                .build();
+        final CtsNetUtils.TestNetworkCallback callback = new CtsNetUtils.TestNetworkCallback();
+        mCM.requestNetwork(request, callback);
+        final FileDescriptor srcTunFd = runWithShellPermissionIdentity(() -> {
+            final TestNetworkManager tnm = mTestContext.getSystemService(TestNetworkManager.class);
+            List<LinkAddress> linkAddresses = duplicatedAddress
+                    ? List.of(new LinkAddress("192.0.2.2/24"),
+                            new LinkAddress("2001:db8:1:2::ffe/64")) :
+                    List.of(new LinkAddress("198.51.100.2/24"),
+                            new LinkAddress("2001:db8:3:4::ffe/64"));
+            final TestNetworkInterface iface = tnm.createTunInterface(linkAddresses);
+            tnm.setupTestNetwork(iface.getInterfaceName(), new Binder());
+            return iface.getFileDescriptor().getFileDescriptor();
+        }, MANAGE_TEST_NETWORKS);
+        final Network testNetwork = callback.waitForAvailable();
+        assertNotNull(testNetwork);
+        final DatagramSocket dstSock = new DatagramSocket();
+
+        testAndCleanup(() -> {
+            startVpn(new String[] {"192.0.2.2/32", "2001:db8:1:2::ffe/128"} /* addresses */,
+                    new String[]{"0.0.0.0/0", "::/0"} /* routes */,
+                    "" /* allowedApplications */, "" /* disallowedApplications */,
+                    null /* proxyInfo */, null /* underlyingNetworks */,
+                    false /* isAlwaysMetered */);
+
+            final FileDescriptor dstUdpFd = dstSock.getFileDescriptor$();
+            checkBlockUdp(srcTunFd, dstUdpFd,
+                    InetAddresses.parseNumericAddress("192.0.2.2") /* dstAddress */,
+                    InetAddresses.parseNumericAddress("192.0.2.1") /* srcAddress */,
+                    duplicatedAddress ? EXPECT_PASS : EXPECT_BLOCK);
+            checkBlockUdp(srcTunFd, dstUdpFd,
+                    InetAddresses.parseNumericAddress("2001:db8:1:2::ffe") /* dstAddress */,
+                    InetAddresses.parseNumericAddress("2001:db8:1:2::ffa") /* srcAddress */,
+                    duplicatedAddress ? EXPECT_PASS : EXPECT_BLOCK);
+
+            // Traffic on VPN should not be affected
+            checkTrafficOnVpn();
+        }, /* cleanup */ () -> {
+                Os.close(srcTunFd);
+                dstSock.close();
+            }, /* cleanup */ () -> {
+                runWithShellPermissionIdentity(() -> {
+                    mTestContext.getSystemService(TestNetworkManager.class)
+                            .teardownTestNetwork(testNetwork);
+                }, MANAGE_TEST_NETWORKS);
+            }, /* cleanup */ () -> {
+                mCM.unregisterNetworkCallback(callback);
+            });
+    }
+
+    @Test @IgnoreUpTo(Build.VERSION_CODES.S_V2)
+    public void testDropPacketToVpnAddress_WithoutDuplicatedAddress() throws Exception {
+        doTestDropPacketToVpnAddress(false /* duplicatedAddress */);
+    }
+
+    @Test @IgnoreUpTo(Build.VERSION_CODES.S_V2)
+    public void testDropPacketToVpnAddress_WithDuplicatedAddress() throws Exception {
+        doTestDropPacketToVpnAddress(true /* duplicatedAddress */);
+    }
+
     private ByteBuffer buildIpv4UdpPacket(final Inet4Address dstAddr, final Inet4Address srcAddr,
             final short dstPort, final short srcPort, final byte[] payload) throws IOException {
 
@@ -1996,7 +2040,8 @@
     private void checkBlockUdp(
             final FileDescriptor srcTunFd,
             final FileDescriptor dstUdpFd,
-            final boolean ipv6,
+            final InetAddress dstAddress,
+            final InetAddress srcAddress,
             final boolean expectBlock) throws Exception {
         final Random random = new Random();
         final byte[] sendData = new byte[100];
@@ -2004,15 +2049,15 @@
         final short dstPort = (short) ((InetSocketAddress) Os.getsockname(dstUdpFd)).getPort();
 
         ByteBuffer buf;
-        if (ipv6) {
+        if (dstAddress instanceof Inet6Address) {
             buf = buildIpv6UdpPacket(
-                    (Inet6Address) TEST_IP6_DST_ADDR.getAddress(),
-                    (Inet6Address) TEST_IP6_SRC_ADDR.getAddress(),
+                    (Inet6Address) dstAddress,
+                    (Inet6Address) srcAddress,
                     dstPort, TEST_SRC_PORT, sendData);
         } else {
             buf = buildIpv4UdpPacket(
-                    (Inet4Address) TEST_IP4_DST_ADDR.getAddress(),
-                    (Inet4Address) TEST_IP4_SRC_ADDR.getAddress(),
+                    (Inet4Address) dstAddress,
+                    (Inet4Address) srcAddress,
                     dstPort, TEST_SRC_PORT, sendData);
         }
 
@@ -2038,8 +2083,10 @@
             final FileDescriptor srcTunFd,
             final FileDescriptor dstUdpFd,
             final boolean expectBlock) throws Exception {
-        checkBlockUdp(srcTunFd, dstUdpFd, false /* ipv6 */, expectBlock);
-        checkBlockUdp(srcTunFd, dstUdpFd, true /* ipv6 */, expectBlock);
+        checkBlockUdp(srcTunFd, dstUdpFd, TEST_IP4_DST_ADDR.getAddress(),
+                TEST_IP4_SRC_ADDR.getAddress(), expectBlock);
+        checkBlockUdp(srcTunFd, dstUdpFd, TEST_IP6_DST_ADDR.getAddress(),
+                TEST_IP6_SRC_ADDR.getAddress(), expectBlock);
     }
 
     private class DetailedBlockedStatusCallback extends TestableNetworkCallback {
diff --git a/tests/cts/hostside/src/com/android/cts/net/HostsideVpnTests.java b/tests/cts/hostside/src/com/android/cts/net/HostsideVpnTests.java
index 4f21af7..f0a87af 100644
--- a/tests/cts/hostside/src/com/android/cts/net/HostsideVpnTests.java
+++ b/tests/cts/hostside/src/com/android/cts/net/HostsideVpnTests.java
@@ -171,4 +171,16 @@
     public void testSetVpnDefaultForUids() throws Exception {
         runDeviceTests(TEST_PKG, TEST_PKG + ".VpnTest", "testSetVpnDefaultForUids");
     }
+
+    @Test
+    public void testDropPacketToVpnAddress_WithoutDuplicatedAddress() throws Exception {
+        runDeviceTests(TEST_PKG, TEST_PKG + ".VpnTest",
+                "testDropPacketToVpnAddress_WithoutDuplicatedAddress");
+    }
+
+    @Test
+    public void testDropPacketToVpnAddress_WithDuplicatedAddress() throws Exception {
+        runDeviceTests(TEST_PKG, TEST_PKG + ".VpnTest",
+                "testDropPacketToVpnAddress_WithDuplicatedAddress");
+    }
 }
diff --git a/tests/cts/multidevices/snippet/ConnectivityMultiDevicesSnippet.kt b/tests/cts/multidevices/snippet/ConnectivityMultiDevicesSnippet.kt
index 115210b..c883b78 100644
--- a/tests/cts/multidevices/snippet/ConnectivityMultiDevicesSnippet.kt
+++ b/tests/cts/multidevices/snippet/ConnectivityMultiDevicesSnippet.kt
@@ -35,6 +35,7 @@
 import android.net.wifi.WifiSsid
 import androidx.test.platform.app.InstrumentationRegistry
 import com.android.testutils.ConnectUtil
+import com.android.testutils.NetworkCallbackHelper
 import com.android.testutils.RecorderCallback.CallbackEntry.Available
 import com.android.testutils.RecorderCallback.CallbackEntry.CapabilitiesChanged
 import com.android.testutils.TestableNetworkCallback
@@ -48,9 +49,14 @@
     private val cm = context.getSystemService(ConnectivityManager::class.java)!!
     private val pm = context.packageManager
     private val ctsNetUtils = CtsNetUtils(context)
+    private val cbHelper = NetworkCallbackHelper()
     private val ctsTetheringUtils = CtsTetheringUtils(context)
     private var oldSoftApConfig: SoftApConfiguration? = null
 
+    override fun shutdown() {
+        cbHelper.unregisterAll()
+    }
+
     @Rpc(description = "Check whether the device has wifi feature.")
     fun hasWifiFeature() = pm.hasSystemFeature(FEATURE_WIFI)
 
@@ -65,13 +71,13 @@
     @Rpc(description = "Request cellular connection and ensure it is the default network.")
     fun requestCellularAndEnsureDefault() {
         ctsNetUtils.disableWifi()
-        val network = ctsNetUtils.connectToCell()
+        val network = cbHelper.requestCell()
         ctsNetUtils.expectNetworkIsSystemDefault(network)
     }
 
     @Rpc(description = "Unrequest cellular connection.")
     fun unrequestCellular() {
-        ctsNetUtils.disconnectFromCell()
+        cbHelper.unrequestCell()
     }
 
     @Rpc(description = "Ensure any wifi is connected and is the default network.")
diff --git a/tests/cts/net/src/android/net/cts/ApfIntegrationTest.kt b/tests/cts/net/src/android/net/cts/ApfIntegrationTest.kt
new file mode 100644
index 0000000..a2e4ab6
--- /dev/null
+++ b/tests/cts/net/src/android/net/cts/ApfIntegrationTest.kt
@@ -0,0 +1,126 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package android.net.cts
+
+import android.Manifest.permission.WRITE_DEVICE_CONFIG
+import android.content.pm.PackageManager.FEATURE_WIFI
+import android.net.ConnectivityManager
+import android.net.NetworkCapabilities
+import android.net.NetworkRequest
+import android.net.apf.ApfCapabilities
+import android.os.Build
+import android.platform.test.annotations.AppModeFull
+import android.provider.DeviceConfig
+import android.provider.DeviceConfig.NAMESPACE_CONNECTIVITY
+import android.system.OsConstants
+import androidx.test.platform.app.InstrumentationRegistry
+import com.android.compatibility.common.util.PropertyUtil.isVendorApiLevelNewerThan
+import com.android.compatibility.common.util.SystemUtil.runShellCommandOrThrow
+import com.android.testutils.DevSdkIgnoreRule
+import com.android.testutils.DevSdkIgnoreRunner
+import com.android.testutils.NetworkStackModuleTest
+import com.android.testutils.RecorderCallback.CallbackEntry.LinkPropertiesChanged
+import com.android.testutils.TestableNetworkCallback
+import com.android.testutils.runAsShell
+import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.TruthJUnit.assume
+import kotlin.test.assertNotNull
+import org.junit.After
+import org.junit.Before
+import org.junit.BeforeClass
+import org.junit.Test
+import org.junit.runner.RunWith
+
+private const val TIMEOUT_MS = 2000L
+private const val APF_NEW_RA_FILTER_VERSION = "apf_new_ra_filter_version"
+
+@AppModeFull(reason = "CHANGE_NETWORK_STATE permission can't be granted to instant apps")
+@RunWith(DevSdkIgnoreRunner::class)
+@NetworkStackModuleTest
+@DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.TIRAMISU)
+class ApfIntegrationTest {
+    companion object {
+        @BeforeClass
+        @JvmStatic
+        fun setupOnce() {
+            // TODO: check that there is no active wifi network. Otherwise, ApfFilter has already been
+            // created.
+            // APF adb cmds are only implemented in ApfFilter.java. Enable experiment to prevent
+            // LegacyApfFilter.java from being used.
+            runAsShell(WRITE_DEVICE_CONFIG) {
+                DeviceConfig.setProperty(
+                        NAMESPACE_CONNECTIVITY,
+                        APF_NEW_RA_FILTER_VERSION,
+                        "1",  // value => force enabled
+                        false // makeDefault
+                )
+            }
+        }
+    }
+
+    private val context by lazy { InstrumentationRegistry.getInstrumentation().context }
+    private val cm by lazy { context.getSystemService(ConnectivityManager::class.java)!! }
+    private val pm by lazy { context.packageManager }
+    private lateinit var ifname: String
+    private lateinit var networkCallback: TestableNetworkCallback
+    private lateinit var caps: ApfCapabilities
+
+    fun getApfCapabilities(): ApfCapabilities {
+        val caps = runShellCommandOrThrow("cmd network_stack apf $ifname capabilities").trim()
+        val (version, maxLen, packetFormat) = caps.split(",").map { it.toInt() }
+        return ApfCapabilities(version, maxLen, packetFormat)
+    }
+
+    @Before
+    fun setUp() {
+        assume().that(pm.hasSystemFeature(FEATURE_WIFI)).isTrue()
+        assume().that(isVendorApiLevelNewerThan(Build.VERSION_CODES.TIRAMISU)).isTrue()
+        networkCallback = TestableNetworkCallback()
+        cm.requestNetwork(
+                NetworkRequest.Builder()
+                        .addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
+                        .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
+                        .build(),
+                networkCallback
+        )
+        networkCallback.eventuallyExpect<LinkPropertiesChanged>(TIMEOUT_MS) {
+            ifname = assertNotNull(it.lp.interfaceName)
+            true
+        }
+        runShellCommandOrThrow("cmd network_stack apf $ifname pause")
+        caps = getApfCapabilities()
+    }
+
+    @After
+    fun tearDown() {
+        if (::networkCallback.isInitialized) {
+            cm.unregisterNetworkCallback(networkCallback)
+        }
+        if (::ifname.isInitialized) {
+            runShellCommandOrThrow("cmd network_stack apf $ifname resume")
+        }
+    }
+
+    @Test
+    fun testGetApfCapabilities() {
+        assertThat(caps.apfVersionSupported).isEqualTo(4)
+        assertThat(caps.maximumApfProgramSize).isAtLeast(1024)
+        if (isVendorApiLevelNewerThan(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)) {
+            assertThat(caps.maximumApfProgramSize).isAtLeast(2000)
+        }
+        assertThat(caps.apfPacketFormat).isEqualTo(OsConstants.ARPHRD_ETHER)
+    }
+}
diff --git a/tests/cts/net/src/android/net/cts/BatteryStatsManagerTest.java b/tests/cts/net/src/android/net/cts/BatteryStatsManagerTest.java
index 3e5d0ba..16a7b73 100644
--- a/tests/cts/net/src/android/net/cts/BatteryStatsManagerTest.java
+++ b/tests/cts/net/src/android/net/cts/BatteryStatsManagerTest.java
@@ -48,6 +48,7 @@
 import androidx.test.filters.SdkSuppress;
 import androidx.test.runner.AndroidJUnit4;
 
+import com.android.testutils.AutoReleaseNetworkCallbackRule;
 import com.android.testutils.DevSdkIgnoreRule;
 
 import org.junit.Before;
@@ -67,7 +68,10 @@
 @RunWith(AndroidJUnit4.class)
 @SdkSuppress(minSdkVersion = Build.VERSION_CODES.R) // BatteryStatsManager did not exist on Q
 public class BatteryStatsManagerTest{
-    @Rule
+    @Rule(order = 1)
+    public final AutoReleaseNetworkCallbackRule
+            networkCallbackRule = new AutoReleaseNetworkCallbackRule();
+    @Rule(order = 2)
     public final DevSdkIgnoreRule ignoreRule = new DevSdkIgnoreRule();
     private static final String TAG = BatteryStatsManagerTest.class.getSimpleName();
     private static final String TEST_URL = "https://connectivitycheck.gstatic.com/generate_204";
@@ -145,7 +149,7 @@
             return;
         }
 
-        final Network cellNetwork = mCtsNetUtils.connectToCell();
+        final Network cellNetwork = networkCallbackRule.requestCell();
         final URL url = new URL(TEST_URL);
 
         // Get cellular battery stats
diff --git a/tests/cts/net/src/android/net/cts/CaptivePortalTest.kt b/tests/cts/net/src/android/net/cts/CaptivePortalTest.kt
index 99222dd..07e2024 100644
--- a/tests/cts/net/src/android/net/cts/CaptivePortalTest.kt
+++ b/tests/cts/net/src/android/net/cts/CaptivePortalTest.kt
@@ -47,6 +47,7 @@
 import com.android.modules.utils.build.SdkLevel.isAtLeastR
 import com.android.net.module.util.NetworkStackConstants.TEST_CAPTIVE_PORTAL_HTTPS_URL
 import com.android.net.module.util.NetworkStackConstants.TEST_CAPTIVE_PORTAL_HTTP_URL
+import com.android.testutils.AutoReleaseNetworkCallbackRule
 import com.android.testutils.DeviceConfigRule
 import com.android.testutils.RecorderCallback.CallbackEntry.CapabilitiesChanged
 import com.android.testutils.SkipMainlinePresubmit
@@ -101,9 +102,12 @@
 
     private val server = TestHttpServer("localhost")
 
-    @get:Rule
+    @get:Rule(order = 1)
     val deviceConfigRule = DeviceConfigRule(retryCountBeforeSIfConfigChanged = 5)
 
+    @get:Rule(order = 2)
+    val networkCallbackRule = AutoReleaseNetworkCallbackRule()
+
     companion object {
         @JvmStatic @BeforeClass
         fun setUpClass() {
@@ -144,15 +148,15 @@
         assumeTrue(pm.hasSystemFeature(FEATURE_WIFI))
         assumeFalse(pm.hasSystemFeature(FEATURE_WATCH))
         utils.ensureWifiConnected()
-        val cellNetwork = utils.connectToCell()
+        val cellNetwork = networkCallbackRule.requestCell()
 
         // Verify cell network is validated
         val cellReq = NetworkRequest.Builder()
                 .addTransportType(TRANSPORT_CELLULAR)
                 .addCapability(NET_CAPABILITY_INTERNET)
                 .build()
-        val cellCb = TestableNetworkCallback(timeoutMs = TEST_TIMEOUT_MS)
-        cm.registerNetworkCallback(cellReq, cellCb)
+        val cellCb = networkCallbackRule.registerNetworkCallback(cellReq,
+            TestableNetworkCallback(timeoutMs = TEST_TIMEOUT_MS))
         val cb = cellCb.poll { it.network == cellNetwork &&
                 it is CapabilitiesChanged && it.caps.hasCapability(NET_CAPABILITY_VALIDATED)
         }
@@ -213,8 +217,6 @@
         } finally {
             cm.unregisterNetworkCallback(wifiCb)
             server.stop()
-            // disconnectFromCell should be called after connectToCell
-            utils.disconnectFromCell()
         }
     }
 
diff --git a/tests/cts/net/src/android/net/cts/ConnectivityManagerTest.java b/tests/cts/net/src/android/net/cts/ConnectivityManagerTest.java
index f0edee2..4d465ba 100644
--- a/tests/cts/net/src/android/net/cts/ConnectivityManagerTest.java
+++ b/tests/cts/net/src/android/net/cts/ConnectivityManagerTest.java
@@ -191,6 +191,7 @@
 import com.android.networkstack.apishim.ConstantsShim;
 import com.android.networkstack.apishim.NetworkInformationShimImpl;
 import com.android.networkstack.apishim.common.ConnectivityManagerShim;
+import com.android.testutils.AutoReleaseNetworkCallbackRule;
 import com.android.testutils.CompatUtil;
 import com.android.testutils.ConnectivityModuleTest;
 import com.android.testutils.DevSdkIgnoreRule;
@@ -259,10 +260,14 @@
 
 @RunWith(AndroidJUnit4.class)
 public class ConnectivityManagerTest {
-    @Rule
+    @Rule(order = 1)
     public final DevSdkIgnoreRule ignoreRule = new DevSdkIgnoreRule();
 
-    @Rule
+    @Rule(order = 2)
+    public final AutoReleaseNetworkCallbackRule
+            networkCallbackRule = new AutoReleaseNetworkCallbackRule();
+
+    @Rule(order = 3)
     public final DeviceConfigRule mTestValidationConfigRule = new DeviceConfigRule(
             5 /* retryCountBeforeSIfConfigChanged */);
 
@@ -411,11 +416,6 @@
 
     @After
     public void tearDown() throws Exception {
-        // Release any NetworkRequests filed to connect mobile data.
-        if (mCtsNetUtils.cellConnectAttempted()) {
-            mCtsNetUtils.disconnectFromCell();
-        }
-
         if (TestUtils.shouldTestSApis()) {
             runWithShellPermissionIdentity(
                     () -> mCmShim.setRequireVpnForUids(false, mVpnRequiredUidRanges),
@@ -555,7 +555,7 @@
             throws InterruptedException {
         assumeTrue(mPackageManager.hasSystemFeature(FEATURE_TELEPHONY));
         // Make sure cell is active to retrieve IMSI for verification in later step.
-        final Network cellNetwork = mCtsNetUtils.connectToCell();
+        final Network cellNetwork = networkCallbackRule.requestCell();
         final String subscriberId = getSubscriberIdForCellNetwork(cellNetwork);
         assertFalse(TextUtils.isEmpty(subscriberId));
 
@@ -802,7 +802,9 @@
             assertNull(redactedNormal.getUids());
             assertNull(redactedNormal.getSsid());
             assertNull(redactedNormal.getUnderlyingNetworks());
-            assertEquals(0, redactedNormal.getSubscriptionIds().size());
+            // TODO: Make subIds public and update to verify the size is 2
+            final int subIdsSize = redactedNormal.getSubscriptionIds().size();
+            assertTrue(subIdsSize == 0 || subIdsSize == 2);
             assertEquals(WifiInfo.DEFAULT_MAC_ADDRESS,
                     ((WifiInfo) redactedNormal.getTransportInfo()).getBSSID());
             assertEquals(rssi, ((WifiInfo) redactedNormal.getTransportInfo()).getRssi());
@@ -851,7 +853,7 @@
         assumeTrue(mPackageManager.hasSystemFeature(FEATURE_TELEPHONY));
 
         Network wifiNetwork = mCtsNetUtils.ensureWifiConnected();
-        Network cellNetwork = mCtsNetUtils.connectToCell();
+        Network cellNetwork = networkCallbackRule.requestCell();
         // This server returns the requestor's IP address as the response body.
         URL url = new URL("http://google-ipv6test.appspot.com/ip.js?fmt=text");
         String wifiAddressString = httpGet(wifiNetwork, url);
@@ -2023,7 +2025,7 @@
             return;
         }
 
-        final Network network = mCtsNetUtils.connectToCell();
+        final Network network = networkCallbackRule.requestCell();
         final int supported = getSupportedKeepalivesForNet(network);
         final InetAddress srcAddr = getFirstV4Address(network);
         assumeTrue("This test requires native IPv4", srcAddr != null);
@@ -2198,8 +2200,7 @@
             registerCallbackAndWaitForAvailable(makeWifiNetworkRequest(), wifiCb);
         }
         if (supportTelephony) {
-            // connectToCell needs to be followed by disconnectFromCell, which is called in tearDown
-            mCtsNetUtils.connectToCell();
+            networkCallbackRule.requestCell();
             registerCallbackAndWaitForAvailable(makeCellNetworkRequest(), telephonyCb);
         }
 
@@ -2990,7 +2991,7 @@
         final TestableNetworkCallback wifiCb = new TestableNetworkCallback();
         try {
             // Ensure at least one default network candidate connected.
-            mCtsNetUtils.connectToCell();
+            networkCallbackRule.requestCell();
 
             final Network wifiNetwork = prepareUnvalidatedNetwork();
             // Default network should not be wifi ,but checking that wifi is not the default doesn't
@@ -3032,7 +3033,7 @@
         allowBadWifi();
 
         try {
-            final Network cellNetwork = mCtsNetUtils.connectToCell();
+            final Network cellNetwork = networkCallbackRule.requestCell();
             final Network wifiNetwork = prepareValidatedNetwork();
 
             registerDefaultNetworkCallback(defaultCb);
@@ -3212,8 +3213,6 @@
 
         if (supportWifi) {
             mCtsNetUtils.ensureWifiDisconnected(null /* wifiNetworkToCheck */);
-        } else {
-            mCtsNetUtils.disconnectFromCell();
         }
 
         final CompletableFuture<Boolean> future = new CompletableFuture<>();
@@ -3224,7 +3223,7 @@
             if (supportWifi) {
                 mCtsNetUtils.ensureWifiConnected();
             } else {
-                mCtsNetUtils.connectToCell();
+                networkCallbackRule.requestCell();
             }
             assertTrue(future.get(LISTEN_ACTIVITY_TIMEOUT_MS, TimeUnit.MILLISECONDS));
         }, () -> {
@@ -3265,7 +3264,7 @@
 
         // For testing mobile data preferred uids feature, it needs both wifi and cell network.
         final Network wifiNetwork = mCtsNetUtils.ensureWifiConnected();
-        final Network cellNetwork = mCtsNetUtils.connectToCell();
+        final Network cellNetwork = networkCallbackRule.requestCell();
         final TestableNetworkCallback defaultTrackingCb = new TestableNetworkCallback();
         final TestableNetworkCallback systemDefaultCb = new TestableNetworkCallback();
         final Handler h = new Handler(Looper.getMainLooper());
diff --git a/tests/cts/net/src/android/net/cts/EthernetManagerTest.kt b/tests/cts/net/src/android/net/cts/EthernetManagerTest.kt
index b7e5205..d052551 100644
--- a/tests/cts/net/src/android/net/cts/EthernetManagerTest.kt
+++ b/tests/cts/net/src/android/net/cts/EthernetManagerTest.kt
@@ -348,7 +348,9 @@
         }
     }
 
-    private fun isEthernetSupported() = em != null
+    private fun isEthernetSupported() : Boolean {
+        return context.getSystemService(EthernetManager::class.java) != null
+    }
 
     @Before
     fun setUp() {
@@ -899,6 +901,20 @@
     }
 
     @Test
+    fun testEnableDisableInterface_callbacks() {
+        val iface = createInterface()
+        val listener = EthernetStateListener()
+        addInterfaceStateListener(listener)
+        listener.expectCallback(iface, STATE_LINK_UP, ROLE_CLIENT)
+
+        disableInterface(iface).expectResult(iface.name)
+        listener.expectCallback(iface, STATE_LINK_DOWN, ROLE_CLIENT)
+
+        enableInterface(iface).expectResult(iface.name)
+        listener.expectCallback(iface, STATE_LINK_UP, ROLE_CLIENT)
+    }
+
+    @Test
     fun testUpdateConfiguration_forBothIpConfigAndCapabilities() {
         val iface = createInterface()
         val cb = requestNetwork(ETH_REQUEST.copyWithEthernetSpecifier(iface.name))
diff --git a/tests/cts/net/src/android/net/cts/IpSecBaseTest.java b/tests/cts/net/src/android/net/cts/IpSecBaseTest.java
index 7f710d7..2a6c638 100644
--- a/tests/cts/net/src/android/net/cts/IpSecBaseTest.java
+++ b/tests/cts/net/src/android/net/cts/IpSecBaseTest.java
@@ -26,12 +26,15 @@
 import static android.system.OsConstants.FIONREAD;
 
 import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
 
 import android.content.Context;
 import android.net.ConnectivityManager;
 import android.net.IpSecAlgorithm;
 import android.net.IpSecManager;
 import android.net.IpSecTransform;
+import android.net.IpSecTransformState;
+import android.os.OutcomeReceiver;
 import android.platform.test.annotations.AppModeFull;
 import android.system.ErrnoException;
 import android.system.Os;
@@ -65,8 +68,12 @@
 import java.net.SocketImpl;
 import java.net.SocketOptions;
 import java.util.Arrays;
+import java.util.BitSet;
 import java.util.HashSet;
 import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
 
 @RunWith(AndroidJUnit4.class)
@@ -83,6 +90,7 @@
     protected static final byte[] TEST_DATA = "Best test data ever!".getBytes();
     protected static final int DATA_BUFFER_LEN = 4096;
     protected static final int SOCK_TIMEOUT = 500;
+    protected static final int REPLAY_BITMAP_LEN_BYTE = 512;
 
     private static final byte[] KEY_DATA = {
         0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
@@ -122,6 +130,47 @@
                                 .getSystemService(Context.CONNECTIVITY_SERVICE);
     }
 
+    protected static void checkTransformState(
+            IpSecTransform transform,
+            long txHighestSeqNum,
+            long rxHighestSeqNum,
+            long packetCnt,
+            long byteCnt,
+            byte[] replayBitmap)
+            throws Exception {
+        final CompletableFuture<IpSecTransformState> futureIpSecTransform =
+                new CompletableFuture<>();
+        transform.requestIpSecTransformState(
+                Executors.newSingleThreadExecutor(),
+                new OutcomeReceiver<IpSecTransformState, RuntimeException>() {
+                    @Override
+                    public void onResult(IpSecTransformState state) {
+                        futureIpSecTransform.complete(state);
+                    }
+                });
+
+        final IpSecTransformState transformState =
+                futureIpSecTransform.get(SOCK_TIMEOUT, TimeUnit.MILLISECONDS);
+
+        assertEquals(txHighestSeqNum, transformState.getTxHighestSequenceNumber());
+        assertEquals(rxHighestSeqNum, transformState.getRxHighestSequenceNumber());
+        assertEquals(packetCnt, transformState.getPacketCount());
+        assertEquals(byteCnt, transformState.getByteCount());
+        assertArrayEquals(replayBitmap, transformState.getReplayBitmap());
+    }
+
+    protected static void checkTransformStateNoTraffic(IpSecTransform transform) throws Exception {
+        checkTransformState(transform, 0L, 0L, 0L, 0L, newReplayBitmap(0));
+    }
+
+    protected static byte[] newReplayBitmap(int receivedPktCnt) {
+        final BitSet bitSet = new BitSet(REPLAY_BITMAP_LEN_BYTE * 8);
+        for (int i = 0; i < receivedPktCnt; i++) {
+            bitSet.set(i);
+        }
+        return Arrays.copyOf(bitSet.toByteArray(), REPLAY_BITMAP_LEN_BYTE);
+    }
+
     /** Checks if an IPsec algorithm is enabled on the device */
     protected static boolean hasIpSecAlgorithm(String algorithm) {
         if (SdkLevel.isAtLeastS()) {
diff --git a/tests/cts/net/src/android/net/cts/IpSecManagerTest.java b/tests/cts/net/src/android/net/cts/IpSecManagerTest.java
index fe86a90..a40ed0f 100644
--- a/tests/cts/net/src/android/net/cts/IpSecManagerTest.java
+++ b/tests/cts/net/src/android/net/cts/IpSecManagerTest.java
@@ -63,11 +63,13 @@
 import static org.junit.Assert.fail;
 import static org.junit.Assume.assumeTrue;
 
+import android.net.InetAddresses;
 import android.net.IpSecAlgorithm;
 import android.net.IpSecManager;
 import android.net.IpSecManager.SecurityParameterIndex;
 import android.net.IpSecManager.UdpEncapsulationSocket;
 import android.net.IpSecTransform;
+import android.net.NetworkUtils;
 import android.net.TrafficStats;
 import android.os.Build;
 import android.platform.test.annotations.AppModeFull;
@@ -381,6 +383,22 @@
         assumeTrue("Not supported by kernel", isIpv6UdpEncapSupportedByKernel());
     }
 
+    // TODO: b/319532485 Figure out whether to support x86_32
+    private static boolean isRequestTransformStateSupportedByKernel() {
+        return NetworkUtils.isKernel64Bit() || !NetworkUtils.isKernelX86();
+    }
+
+    // Package private for use in IpSecManagerTunnelTest
+    static boolean isRequestTransformStateSupported() {
+        return SdkLevel.isAtLeastV() && isRequestTransformStateSupportedByKernel();
+    }
+
+    // Package private for use in IpSecManagerTunnelTest
+    static void assumeRequestIpSecTransformStateSupported() {
+        assumeTrue("Not supported before V", SdkLevel.isAtLeastV());
+        assumeTrue("Not supported by kernel", isRequestTransformStateSupportedByKernel());
+    }
+
     @Test
     public void testCreateTransformIpv4() throws Exception {
         doTestCreateTransform(IPV4_LOOPBACK, false);
@@ -1596,4 +1614,32 @@
             assertTrue("Returned invalid port", encapSocket.getPort() != 0);
         }
     }
+
+    @IgnoreUpTo(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+    @Test
+    public void testRequestIpSecTransformState() throws Exception {
+        assumeRequestIpSecTransformStateSupported();
+
+        final InetAddress localAddr = InetAddresses.parseNumericAddress(IPV6_LOOPBACK);
+        try (SecurityParameterIndex spi = mISM.allocateSecurityParameterIndex(localAddr);
+                IpSecTransform transform =
+                        buildTransportModeTransform(spi, localAddr, null /* encapSocket*/)) {
+            final SocketPair<JavaUdpSocket> sockets =
+                    getJavaUdpSocketPair(localAddr, mISM, transform, false);
+
+            sockets.mLeftSock.sendTo(TEST_DATA, localAddr, sockets.mRightSock.getPort());
+            sockets.mRightSock.receive();
+
+            final int expectedPacketCount = 1;
+            final int expectedInnerPacketSize = TEST_DATA.length + UDP_HDRLEN;
+
+            checkTransformState(
+                    transform,
+                    expectedPacketCount,
+                    expectedPacketCount,
+                    2 * (long) expectedPacketCount,
+                    2 * (long) expectedInnerPacketSize,
+                    newReplayBitmap(expectedPacketCount));
+        }
+    }
 }
diff --git a/tests/cts/net/src/android/net/cts/IpSecManagerTunnelTest.java b/tests/cts/net/src/android/net/cts/IpSecManagerTunnelTest.java
index 1ede5c1..22a51d6 100644
--- a/tests/cts/net/src/android/net/cts/IpSecManagerTunnelTest.java
+++ b/tests/cts/net/src/android/net/cts/IpSecManagerTunnelTest.java
@@ -19,7 +19,9 @@
 import static android.app.AppOpsManager.OP_MANAGE_IPSEC_TUNNELS;
 import static android.net.IpSecManager.UdpEncapsulationSocket;
 import static android.net.cts.IpSecManagerTest.assumeExperimentalIpv6UdpEncapSupported;
+import static android.net.cts.IpSecManagerTest.assumeRequestIpSecTransformStateSupported;
 import static android.net.cts.IpSecManagerTest.isIpv6UdpEncapSupported;
+import static android.net.cts.IpSecManagerTest.isRequestTransformStateSupported;
 import static android.net.cts.PacketUtils.AES_CBC_BLK_SIZE;
 import static android.net.cts.PacketUtils.AES_CBC_IV_LEN;
 import static android.net.cts.PacketUtils.BytePayload;
@@ -117,6 +119,8 @@
 
     private static final int TIMEOUT_MS = 500;
 
+    private static final int PACKET_COUNT = 5000;
+
     // Static state to reduce setup/teardown
     private static ConnectivityManager sCM;
     private static TestNetworkManager sTNM;
@@ -256,7 +260,7 @@
     }
 
     /* Test runnables for callbacks after IPsec tunnels are set up. */
-    private abstract class IpSecTunnelTestRunnable {
+    private interface IpSecTunnelTestRunnable {
         /**
          * Runs the test code, and returns the inner socket port, if any.
          *
@@ -282,8 +286,7 @@
                 throws Exception;
     }
 
-    private int getPacketSize(
-            int innerFamily, int outerFamily, boolean useEncap, boolean transportInTunnelMode) {
+    private static int getInnerPacketSize(int innerFamily, boolean transportInTunnelMode) {
         int expectedPacketSize = TEST_DATA.length + UDP_HDRLEN;
 
         // Inner Transport mode packet size
@@ -299,6 +302,13 @@
         // Inner IP Header
         expectedPacketSize += innerFamily == AF_INET ? IP4_HDRLEN : IP6_HDRLEN;
 
+        return expectedPacketSize;
+    }
+
+    private static int getPacketSize(
+            int innerFamily, int outerFamily, boolean useEncap, boolean transportInTunnelMode) {
+        int expectedPacketSize = getInnerPacketSize(innerFamily, transportInTunnelMode);
+
         // Tunnel mode transform size
         expectedPacketSize =
                 PacketUtils.calculateEspPacketSize(
@@ -401,6 +411,20 @@
                             spi, TEST_DATA, useEncap, expectedPacketSize);
                     socket.close();
 
+                    if (isRequestTransformStateSupported()) {
+                        final int innerPacketSize =
+                                getInnerPacketSize(innerFamily, transportInTunnelMode);
+
+                        checkTransformState(
+                                outTunnelTransform,
+                                seqNum,
+                                0L,
+                                seqNum,
+                                seqNum * (long) innerPacketSize,
+                                newReplayBitmap(0));
+                        checkTransformStateNoTraffic(inTunnelTransform);
+                    }
+
                     return innerSocketPort;
                 }
             };
@@ -524,6 +548,22 @@
 
                     socket.close();
 
+                    if (isRequestTransformStateSupported()) {
+                        final int innerFamily =
+                                localInner instanceof Inet4Address ? AF_INET : AF_INET6;
+                        final int innerPacketSize =
+                                getInnerPacketSize(innerFamily, transportInTunnelMode);
+
+                        checkTransformStateNoTraffic(outTunnelTransform);
+                        checkTransformState(
+                                inTunnelTransform,
+                                0L,
+                                seqNum,
+                                seqNum,
+                                seqNum * (long) innerPacketSize,
+                                newReplayBitmap(seqNum));
+                    }
+
                     return 0;
                 }
             };
@@ -1127,6 +1167,18 @@
         return innerSocketPort;
     }
 
+    private int buildTunnelNetworkAndRunTestsSimple(int spi, IpSecTunnelTestRunnable test)
+            throws Exception {
+        return buildTunnelNetworkAndRunTests(
+                LOCAL_INNER_6,
+                REMOTE_INNER_6,
+                LOCAL_OUTER_6,
+                REMOTE_OUTER_6,
+                spi,
+                null /* encapSocket */,
+                test);
+    }
+
     private static void receiveAndValidatePacket(JavaUdpSocket socket) throws Exception {
         byte[] socketResponseBytes = socket.receive();
         assertArrayEquals(TEST_DATA, socketResponseBytes);
@@ -1691,4 +1743,101 @@
         assumeExperimentalIpv6UdpEncapSupported();
         doTestMigrateTunnelModeTransform(AF_INET6, AF_INET6, true, false);
     }
+
+    @IgnoreUpTo(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+    @Test
+    public void testRequestIpSecTransformStateForRx() throws Exception {
+        assumeRequestIpSecTransformStateSupported();
+
+        final int spi = getRandomSpi(LOCAL_OUTER_6, REMOTE_OUTER_6);
+        buildTunnelNetworkAndRunTestsSimple(
+                spi,
+                (ipsecNetwork,
+                        tunnelIface,
+                        tunUtils,
+                        inTunnelTransform,
+                        outTunnelTransform,
+                        localOuter,
+                        remoteOuter,
+                        seqNum) -> {
+                    // Build a socket and send traffic
+                    final JavaUdpSocket socket = new JavaUdpSocket(LOCAL_INNER_6);
+                    ipsecNetwork.bindSocket(socket.mSocket);
+                    int innerSocketPort = socket.getPort();
+
+                    for (int i = 1; i < PACKET_COUNT + 1; i++) {
+                        byte[] pkt =
+                                getTunnelModePacket(
+                                        spi,
+                                        REMOTE_INNER_6,
+                                        LOCAL_INNER_6,
+                                        remoteOuter,
+                                        localOuter,
+                                        innerSocketPort,
+                                        0,
+                                        i);
+                        tunUtils.injectPacket(pkt);
+                        receiveAndValidatePacket(socket);
+                    }
+
+                    final int innerPacketSize = getInnerPacketSize(AF_INET6, false);
+                    checkTransformState(
+                            inTunnelTransform,
+                            0L,
+                            PACKET_COUNT,
+                            PACKET_COUNT,
+                            PACKET_COUNT * (long) innerPacketSize,
+                            newReplayBitmap(REPLAY_BITMAP_LEN_BYTE * 8));
+
+                    return innerSocketPort;
+                });
+    }
+
+    @IgnoreUpTo(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+    @Test
+    public void testRequestIpSecTransformStateForTx() throws Exception {
+        assumeRequestIpSecTransformStateSupported();
+
+        final int spi = getRandomSpi(LOCAL_OUTER_6, REMOTE_OUTER_6);
+        buildTunnelNetworkAndRunTestsSimple(
+                spi,
+                (ipsecNetwork,
+                        tunnelIface,
+                        tunUtils,
+                        inTunnelTransform,
+                        outTunnelTransform,
+                        localOuter,
+                        remoteOuter,
+                        seqNum) -> {
+                    // Build a socket and send traffic
+                    final JavaUdpSocket outSocket = new JavaUdpSocket(LOCAL_INNER_6);
+                    ipsecNetwork.bindSocket(outSocket.mSocket);
+                    int innerSocketPort = outSocket.getPort();
+
+                    int expectedPacketSize =
+                            getPacketSize(
+                                    AF_INET6,
+                                    AF_INET6,
+                                    false /* useEncap */,
+                                    false /* transportInTunnelMode */);
+
+                    for (int i = 0; i < PACKET_COUNT; i++) {
+                        outSocket.sendTo(TEST_DATA, REMOTE_INNER_6, innerSocketPort);
+                        tunUtils.awaitEspPacketNoPlaintext(
+                                spi, TEST_DATA, false /* useEncap */, expectedPacketSize);
+                    }
+
+                    final int innerPacketSize =
+                            getInnerPacketSize(AF_INET6, false /* transportInTunnelMode */);
+                    checkTransformState(
+                            outTunnelTransform,
+                            PACKET_COUNT,
+                            0L,
+                            PACKET_COUNT,
+                            PACKET_COUNT * (long) innerPacketSize,
+                            newReplayBitmap(0));
+
+                    return innerSocketPort;
+                });
+    }
 }
diff --git a/tests/cts/net/src/android/net/cts/MultinetworkApiTest.java b/tests/cts/net/src/android/net/cts/MultinetworkApiTest.java
index bca18f5..73f65e0 100644
--- a/tests/cts/net/src/android/net/cts/MultinetworkApiTest.java
+++ b/tests/cts/net/src/android/net/cts/MultinetworkApiTest.java
@@ -16,10 +16,14 @@
 
 package android.net.cts;
 
+import static android.content.pm.PackageManager.FEATURE_TELEPHONY;
+import static android.content.pm.PackageManager.FEATURE_WIFI;
 import static android.net.NetworkCapabilities.TRANSPORT_CELLULAR;
+import static android.net.NetworkCapabilities.TRANSPORT_WIFI;
 import static android.provider.DeviceConfig.NAMESPACE_CONNECTIVITY;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertNull;
 import static org.junit.Assert.fail;
@@ -34,10 +38,12 @@
 import android.platform.test.annotations.AppModeFull;
 import android.system.ErrnoException;
 import android.system.OsConstants;
+import android.util.ArraySet;
 
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.platform.app.InstrumentationRegistry;
 
+import com.android.testutils.AutoReleaseNetworkCallbackRule;
 import com.android.testutils.DeviceConfigRule;
 
 import org.junit.Before;
@@ -45,11 +51,17 @@
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
+import java.util.Set;
+
 @RunWith(AndroidJUnit4.class)
 public class MultinetworkApiTest {
-    @Rule
+    @Rule(order = 1)
     public final DeviceConfigRule mDeviceConfigRule = new DeviceConfigRule();
 
+    @Rule(order = 2)
+    public final AutoReleaseNetworkCallbackRule
+            mNetworkCallbackRule = new AutoReleaseNetworkCallbackRule();
+
     static {
         System.loadLibrary("nativemultinetwork_jni");
     }
@@ -74,9 +86,8 @@
     private ContentResolver mCR;
     private ConnectivityManager mCM;
     private CtsNetUtils mCtsNetUtils;
-    private String mOldMode;
-    private String mOldDnsSpecifier;
     private Context mContext;
+    private Network mRequestedCellNetwork;
 
     @Before
     public void setUp() throws Exception {
@@ -87,8 +98,8 @@
     }
 
     @Test
-    public void testGetaddrinfo() throws ErrnoException {
-        for (Network network : mCtsNetUtils.getTestableNetworks()) {
+    public void testGetaddrinfo() throws Exception {
+        for (Network network : getTestableNetworks()) {
             int errno = runGetaddrinfoCheck(network.getNetworkHandle());
             if (errno != 0) {
                 throw new ErrnoException(
@@ -99,12 +110,12 @@
 
     @Test
     @AppModeFull(reason = "CHANGE_NETWORK_STATE permission can't be granted to instant apps")
-    public void testSetprocnetwork() throws ErrnoException {
+    public void testSetprocnetwork() throws Exception {
         // Hopefully no prior test in this process space has set a default network.
         assertNull(mCM.getProcessDefaultNetwork());
         assertEquals(0, NetworkUtils.getBoundNetworkForProcess());
 
-        for (Network network : mCtsNetUtils.getTestableNetworks()) {
+        for (Network network : getTestableNetworks()) {
             mCM.setProcessDefaultNetwork(null);
             assertNull(mCM.getProcessDefaultNetwork());
 
@@ -123,7 +134,7 @@
             mCM.setProcessDefaultNetwork(null);
         }
 
-        for (Network network : mCtsNetUtils.getTestableNetworks()) {
+        for (Network network : getTestableNetworks()) {
             NetworkUtils.bindProcessToNetwork(0);
             assertNull(mCM.getBoundNetworkForProcess());
 
@@ -143,8 +154,8 @@
 
     @Test
     @AppModeFull(reason = "CHANGE_NETWORK_STATE permission can't be granted to instant apps")
-    public void testSetsocknetwork() throws ErrnoException {
-        for (Network network : mCtsNetUtils.getTestableNetworks()) {
+    public void testSetsocknetwork() throws Exception {
+        for (Network network : getTestableNetworks()) {
             int errno = runSetsocknetwork(network.getNetworkHandle());
             if (errno != 0) {
                 throw new ErrnoException(
@@ -154,8 +165,8 @@
     }
 
     @Test
-    public void testNativeDatagramTransmission() throws ErrnoException {
-        for (Network network : mCtsNetUtils.getTestableNetworks()) {
+    public void testNativeDatagramTransmission() throws Exception {
+        for (Network network : getTestableNetworks()) {
             int errno = runDatagramCheck(network.getNetworkHandle());
             if (errno != 0) {
                 throw new ErrnoException(
@@ -165,7 +176,7 @@
     }
 
     @Test
-    public void testNoSuchNetwork() {
+    public void testNoSuchNetwork() throws Exception {
         final Network eNoNet = new Network(54321);
         assertNull(mCM.getNetworkInfo(eNoNet));
 
@@ -178,9 +189,9 @@
     }
 
     @Test
-    public void testNetworkHandle() {
+    public void testNetworkHandle() throws Exception {
         // Test Network -> NetworkHandle -> Network results in the same Network.
-        for (Network network : mCtsNetUtils.getTestableNetworks()) {
+        for (Network network : getTestableNetworks()) {
             long networkHandle = network.getNetworkHandle();
             Network newNetwork = Network.fromNetworkHandle(networkHandle);
             assertEquals(newNetwork, network);
@@ -203,9 +214,7 @@
 
     @Test
     public void testResNApi() throws Exception {
-        final Network[] testNetworks = mCtsNetUtils.getTestableNetworks();
-
-        for (Network network : testNetworks) {
+        for (Network network : getTestableNetworks()) {
             // Throws AssertionError directly in jni function if test fail.
             runResNqueryCheck(network.getNetworkHandle());
             runResNsendCheck(network.getNetworkHandle());
@@ -241,7 +250,7 @@
         // b/144521720
         try {
             mCtsNetUtils.setPrivateDnsStrictMode(GOOGLE_PRIVATE_DNS_SERVER);
-            for (Network network : mCtsNetUtils.getTestableNetworks()) {
+            for (Network network : getTestableNetworks()) {
               // Wait for private DNS setting to propagate.
               mCtsNetUtils.awaitPrivateDnsSetting("NxDomain test wait private DNS setting timeout",
                         network, GOOGLE_PRIVATE_DNS_SERVER, true);
@@ -251,4 +260,44 @@
             mCtsNetUtils.restorePrivateDnsSetting();
         }
     }
+
+    /**
+     * Get all testable Networks with internet capability.
+     */
+    private Set<Network> getTestableNetworks() throws InterruptedException {
+        // Obtain cell and Wi-Fi through CtsNetUtils (which uses NetworkCallbacks), as they may have
+        // just been reconnected by the test using NetworkCallbacks, so synchronous calls may not
+        // yet return them (synchronous calls and callbacks should not be mixed for a given
+        // Network).
+        final Set<Network> testableNetworks = new ArraySet<>();
+        if (mContext.getPackageManager().hasSystemFeature(FEATURE_TELEPHONY)) {
+            if (mRequestedCellNetwork == null) {
+                mRequestedCellNetwork = mNetworkCallbackRule.requestCell();
+            }
+            assertNotNull("Cell network requested but not obtained", mRequestedCellNetwork);
+            testableNetworks.add(mRequestedCellNetwork);
+        }
+
+        if (mContext.getPackageManager().hasSystemFeature(FEATURE_WIFI)) {
+            testableNetworks.add(mCtsNetUtils.ensureWifiConnected());
+        }
+
+        // Obtain other networks through the synchronous API, if any.
+        for (Network network : mCtsNetUtils.getTestableNetworks()) {
+            final NetworkCapabilities nc = mCM.getNetworkCapabilities(network);
+            if (nc != null
+                    && !nc.hasTransport(TRANSPORT_WIFI)
+                    && !nc.hasTransport(TRANSPORT_CELLULAR)) {
+                testableNetworks.add(network);
+            }
+        }
+
+        // In practice this should not happen as getTestableNetworks throws if there is no network
+        // at all.
+        assertFalse("This device does not support WiFi nor cell data, and does not have any other "
+                        + "network connected. This test requires at least one internet-providing "
+                        + "network.",
+                testableNetworks.isEmpty());
+        return testableNetworks;
+    }
 }
diff --git a/tests/cts/net/src/android/net/cts/NetworkAgentTest.kt b/tests/cts/net/src/android/net/cts/NetworkAgentTest.kt
index 84b6745..beb9274 100644
--- a/tests/cts/net/src/android/net/cts/NetworkAgentTest.kt
+++ b/tests/cts/net/src/android/net/cts/NetworkAgentTest.kt
@@ -21,6 +21,7 @@
 import android.app.Instrumentation
 import android.content.Context
 import android.content.pm.PackageManager
+import android.content.pm.PackageManager.FEATURE_TELEPHONY_SUBSCRIPTION
 import android.net.ConnectivityManager
 import android.net.EthernetNetworkSpecifier
 import android.net.INetworkAgent
@@ -70,6 +71,7 @@
 import android.net.TelephonyNetworkSpecifier
 import android.net.TestNetworkInterface
 import android.net.TestNetworkManager
+import android.net.TransportInfo
 import android.net.Uri
 import android.net.VpnManager
 import android.net.VpnTransportInfo
@@ -150,6 +152,7 @@
 import kotlin.test.assertTrue
 import kotlin.test.fail
 import org.junit.After
+import org.junit.Assume.assumeTrue
 import org.junit.Before
 import org.junit.Test
 import org.junit.runner.RunWith
@@ -574,27 +577,13 @@
     }
 
     private fun doTestAllowedUids(
-            subId: Int,
-            transport: Int,
-            uid: Int,
-            expectUidsPresent: Boolean
-    ) {
-        doTestAllowedUids(subId, intArrayOf(transport), uid, expectUidsPresent)
-    }
-
-    private fun doTestAllowedUids(
-            subId: Int,
             transports: IntArray,
             uid: Int,
-            expectUidsPresent: Boolean
+            expectUidsPresent: Boolean,
+            specifier: NetworkSpecifier?,
+            transportInfo: TransportInfo?
     ) {
         val callback = TestableNetworkCallback(DEFAULT_TIMEOUT_MS)
-        val specifier = when {
-            transports.size != 1 -> null
-            TRANSPORT_ETHERNET in transports -> EthernetNetworkSpecifier("testInterface")
-            TRANSPORT_CELLULAR in transports -> TelephonyNetworkSpecifier(subId)
-            else -> null
-        }
         val agent = createNetworkAgent(initialNc = NetworkCapabilities.Builder().run {
             addTransportType(TRANSPORT_TEST)
             transports.forEach { addTransportType(it) }
@@ -602,10 +591,7 @@
             addCapability(NET_CAPABILITY_NOT_SUSPENDED)
             removeCapability(NET_CAPABILITY_NOT_RESTRICTED)
             setNetworkSpecifier(specifier)
-            if (TRANSPORT_WIFI in transports && SdkLevel.isAtLeastV()) {
-                // setSubscriptionId only exists in V+
-                setTransportInfo(WifiInfo.Builder().setSubscriptionId(subId).build())
-            }
+            setTransportInfo(transportInfo)
             setAllowedUids(setOf(uid))
             setOwnerUid(Process.myUid())
             setAdministratorUids(intArrayOf(Process.myUid()))
@@ -630,6 +616,45 @@
         // callback will be unregistered in tearDown()
     }
 
+    private fun doTestAllowedUids(
+            transport: Int,
+            uid: Int,
+            expectUidsPresent: Boolean
+    ) {
+        doTestAllowedUids(intArrayOf(transport), uid, expectUidsPresent,
+                specifier = null, transportInfo = null)
+    }
+
+    private fun doTestAllowedUidsWithSubId(
+            subId: Int,
+            transport: Int,
+            uid: Int,
+            expectUidsPresent: Boolean
+    ) {
+        doTestAllowedUidsWithSubId(subId, intArrayOf(transport), uid, expectUidsPresent)
+    }
+
+    private fun doTestAllowedUidsWithSubId(
+            subId: Int,
+            transports: IntArray,
+            uid: Int,
+            expectUidsPresent: Boolean
+    ) {
+        val specifier = when {
+            transports.size != 1 -> null
+            TRANSPORT_ETHERNET in transports -> EthernetNetworkSpecifier("testInterface")
+            TRANSPORT_CELLULAR in transports -> TelephonyNetworkSpecifier(subId)
+            else -> null
+        }
+        val transportInfo = if (TRANSPORT_WIFI in transports && SdkLevel.isAtLeastV()) {
+            // setSubscriptionId only exists in V+
+            WifiInfo.Builder().setSubscriptionId(subId).build()
+        } else {
+            null
+        }
+        doTestAllowedUids(transports, uid, expectUidsPresent, specifier, transportInfo)
+    }
+
     private fun setHoldCarrierPrivilege(hold: Boolean, subId: Int) {
         fun getCertHash(): String {
             val pkgInfo = realContext.packageManager.getPackageInfo(realContext.opPackageName,
@@ -723,6 +748,19 @@
     @Test
     @IgnoreUpTo(Build.VERSION_CODES.S)
     fun testAllowedUids() {
+        doTestAllowedUids(TRANSPORT_CELLULAR, Process.myUid(), expectUidsPresent = false)
+        doTestAllowedUids(TRANSPORT_WIFI, Process.myUid(), expectUidsPresent = false)
+        doTestAllowedUids(TRANSPORT_BLUETOOTH, Process.myUid(), expectUidsPresent = false)
+
+        // TODO(b/315136340): Allow ownerUid to see allowedUids and add cases that expect uids
+        // present
+    }
+
+    @Test
+    @IgnoreUpTo(Build.VERSION_CODES.S)
+    fun testAllowedUids_WithCarrierServicePackage() {
+        assumeTrue(realContext.packageManager.hasSystemFeature(FEATURE_TELEPHONY_SUBSCRIPTION))
+
         // Use a different package than this one to make sure that a package that doesn't hold
         // carrier service permission can be set as an allowed UID.
         val servicePackage = "android.net.cts.carrierservicepackage"
@@ -735,12 +773,17 @@
 
         val tm = realContext.getSystemService(TelephonyManager::class.java)!!
         val defaultSubId = SubscriptionManager.getDefaultSubscriptionId()
+        assertTrue(defaultSubId != SubscriptionManager.INVALID_SUBSCRIPTION_ID,
+                "getDefaultSubscriptionId returns INVALID_SUBSCRIPTION_ID")
         tryTest {
             // This process is not the carrier service UID, so allowedUids should be ignored in all
             // the following cases.
-            doTestAllowedUids(defaultSubId, TRANSPORT_CELLULAR, uid, expectUidsPresent = false)
-            doTestAllowedUids(defaultSubId, TRANSPORT_WIFI, uid, expectUidsPresent = false)
-            doTestAllowedUids(defaultSubId, TRANSPORT_BLUETOOTH, uid, expectUidsPresent = false)
+            doTestAllowedUidsWithSubId(defaultSubId, TRANSPORT_CELLULAR, uid,
+                    expectUidsPresent = false)
+            doTestAllowedUidsWithSubId(defaultSubId, TRANSPORT_WIFI, uid,
+                    expectUidsPresent = false)
+            doTestAllowedUidsWithSubId(defaultSubId, TRANSPORT_BLUETOOTH, uid,
+                    expectUidsPresent = false)
 
             // The tools to set the carrier service package override do not exist before U,
             // so there is no way to test the rest of this test on < U.
@@ -783,9 +826,10 @@
                 // TODO(b/315136340): Allow ownerUid to see allowedUids and enable below test case
                 // doTestAllowedUids(defaultSubId, TRANSPORT_WIFI, uid, expectUidsPresent = true)
             }
-            doTestAllowedUids(defaultSubId, TRANSPORT_BLUETOOTH, uid, expectUidsPresent = false)
-            doTestAllowedUids(defaultSubId, intArrayOf(TRANSPORT_CELLULAR, TRANSPORT_WIFI), uid,
+            doTestAllowedUidsWithSubId(defaultSubId, TRANSPORT_BLUETOOTH, uid,
                     expectUidsPresent = false)
+            doTestAllowedUidsWithSubId(defaultSubId, intArrayOf(TRANSPORT_CELLULAR, TRANSPORT_WIFI),
+                    uid, expectUidsPresent = false)
         } cleanupStep {
             if (SdkLevel.isAtLeastU()) setCarrierServicePackageOverride(defaultSubId, null)
         } cleanup {
diff --git a/tests/cts/net/src/android/net/cts/NetworkStatsManagerTest.java b/tests/cts/net/src/android/net/cts/NetworkStatsManagerTest.java
index 6a019b7..2315940 100644
--- a/tests/cts/net/src/android/net/cts/NetworkStatsManagerTest.java
+++ b/tests/cts/net/src/android/net/cts/NetworkStatsManagerTest.java
@@ -805,7 +805,7 @@
                 // harness, which is untagged, won't cause a failure.
                 long firstTotal = resultsWithTraffic.get(0).total;
                 for (QueryResult queryResult : resultsWithTraffic) {
-                    assertWithinPercentage(queryResult + "", firstTotal, queryResult.total, 12);
+                    assertWithinPercentage(queryResult + "", firstTotal, queryResult.total, 16);
                 }
 
                 // Expect to see no traffic when querying for any tag in tagsWithNoTraffic or any
diff --git a/tests/cts/net/src/android/net/cts/NsdManagerTest.kt b/tests/cts/net/src/android/net/cts/NsdManagerTest.kt
index ce2c2c1..6dd4857 100644
--- a/tests/cts/net/src/android/net/cts/NsdManagerTest.kt
+++ b/tests/cts/net/src/android/net/cts/NsdManagerTest.kt
@@ -78,6 +78,7 @@
 import androidx.test.platform.app.InstrumentationRegistry
 import com.android.compatibility.common.util.PollingCheck
 import com.android.compatibility.common.util.PropertyUtil
+import com.android.compatibility.common.util.SystemUtil
 import com.android.modules.utils.build.SdkLevel.isAtLeastU
 import com.android.net.module.util.DnsPacket
 import com.android.net.module.util.HexDump
@@ -1887,6 +1888,64 @@
         }
     }
 
+    @Test
+    fun testQueryWhenKnownAnswerSuppressionFlagSet() {
+        // The flag may be removed in the future but known-answer suppression should be enabled by
+        // default in that case. The rule will reset flags automatically on teardown.
+        deviceConfigRule.setConfig(NAMESPACE_TETHERING, "test_nsd_query_with_known_answer", "1")
+
+        // Register service on testNetwork1
+        val discoveryRecord = NsdDiscoveryRecord()
+        val packetReader = TapPacketReader(Handler(handlerThread.looper),
+                testNetwork1.iface.fileDescriptor.fileDescriptor, 1500 /* maxPacketSize */)
+        packetReader.startAsyncForTest()
+        handlerThread.waitForIdle(TIMEOUT_MS)
+
+        nsdManager.discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD,
+                testNetwork1.network, { it.run() }, discoveryRecord)
+
+        tryTest {
+            discoveryRecord.expectCallback<DiscoveryStarted>()
+            assertNotNull(packetReader.pollForQuery("$serviceType.local", DnsResolver.TYPE_PTR))
+            /*
+            Generated with:
+            scapy.raw(scapy.DNS(rd=0, qr=1, aa=1, qd = None, an =
+                scapy.DNSRR(rrname='_nmt123456789._tcp.local', type='PTR', ttl=120,
+                rdata='NsdTest123456789._nmt123456789._tcp.local'))).hex()
+             */
+            val ptrResponsePayload = HexDump.hexStringToByteArray("0000840000000001000000000d5f6e" +
+                    "6d74313233343536373839045f746370056c6f63616c00000c000100000078002b104e736454" +
+                    "6573743132333435363738390d5f6e6d74313233343536373839045f746370056c6f63616c00")
+
+            replaceServiceNameAndTypeWithTestSuffix(ptrResponsePayload)
+            packetReader.sendResponse(buildMdnsPacket(ptrResponsePayload))
+
+            val serviceFound = discoveryRecord.expectCallback<ServiceFound>()
+            serviceFound.serviceInfo.let {
+                assertEquals(serviceName, it.serviceName)
+                // Discovered service types have a dot at the end
+                assertEquals("$serviceType.", it.serviceType)
+                assertEquals(testNetwork1.network, it.network)
+                // ServiceFound does not provide port, address or attributes (only information
+                // available in the PTR record is included in that callback, regardless of whether
+                // other records exist).
+                assertEquals(0, it.port)
+                assertEmpty(it.hostAddresses)
+                assertEquals(0, it.attributes.size)
+            }
+
+            // Expect the second query with a known answer
+            val query = packetReader.pollForMdnsPacket { pkt ->
+                pkt.isQueryFor("$serviceType.local", DnsResolver.TYPE_PTR) &&
+                        pkt.isReplyFor("$serviceType.local", DnsResolver.TYPE_PTR)
+            }
+            assertNotNull(query)
+        } cleanup {
+            nsdManager.stopServiceDiscovery(discoveryRecord)
+            discoveryRecord.expectCallback<DiscoveryStopped>()
+        }
+    }
+
     private fun makeLinkLocalAddressOfOtherDeviceOnPrefix(network: Network): Inet6Address {
         val lp = cm.getLinkProperties(network) ?: fail("No LinkProperties for net $network")
         // Expect to have a /64 link-local address
@@ -2106,6 +2165,184 @@
         }
     }
 
+    @Test
+    fun testRegisterService_registerImmediatelyAfterUnregister_serviceFound() {
+        val info1 = makeTestServiceInfo(network = testNetwork1.network).apply {
+            serviceName = "service11111"
+            port = 11111
+        }
+        val info2 = makeTestServiceInfo(network = testNetwork1.network).apply {
+            serviceName = "service22222"
+            port = 22222
+        }
+        val registrationRecord1 = NsdRegistrationRecord()
+        val discoveryRecord1 = NsdDiscoveryRecord()
+        val registrationRecord2 = NsdRegistrationRecord()
+        val discoveryRecord2 = NsdDiscoveryRecord()
+        tryTest {
+            registerService(registrationRecord1, info1)
+            nsdManager.discoverServices(serviceType,
+                    NsdManager.PROTOCOL_DNS_SD, testNetwork1.network, { it.run() },
+                    discoveryRecord1)
+            discoveryRecord1.waitForServiceDiscovered(info1.serviceName,
+                    serviceType, testNetwork1.network)
+            nsdManager.stopServiceDiscovery(discoveryRecord1)
+
+            nsdManager.unregisterService(registrationRecord1)
+            registerService(registrationRecord2, info2)
+            nsdManager.discoverServices(serviceType,
+                    NsdManager.PROTOCOL_DNS_SD, testNetwork1.network, { it.run() },
+                    discoveryRecord2)
+            val infoDiscovered = discoveryRecord2.waitForServiceDiscovered(info2.serviceName,
+                    serviceType, testNetwork1.network)
+            val infoResolved = resolveService(infoDiscovered)
+            assertEquals(22222, infoResolved.port)
+        } cleanupStep {
+            nsdManager.stopServiceDiscovery(discoveryRecord2)
+            discoveryRecord2.expectCallback<DiscoveryStopped>()
+        } cleanup {
+            nsdManager.unregisterService(registrationRecord2)
+        }
+    }
+
+    @Test
+    fun testAdvertisingAndDiscovery_reregisterCustomHostWithDifferentAddresses_newAddressesFound() {
+        val si1 = NsdServiceInfo().also {
+            it.network = testNetwork1.network
+            it.hostname = customHostname
+            it.hostAddresses = listOf(
+                    parseNumericAddress("192.0.2.23"),
+                    parseNumericAddress("2001:db8::1"))
+        }
+        val si2 = NsdServiceInfo().also {
+            it.network = testNetwork1.network
+            it.serviceName = serviceName
+            it.serviceType = serviceType
+            it.hostname = customHostname
+            it.port = TEST_PORT
+        }
+        val si3 = NsdServiceInfo().also {
+            it.network = testNetwork1.network
+            it.hostname = customHostname
+            it.hostAddresses = listOf(
+                    parseNumericAddress("192.0.2.24"),
+                    parseNumericAddress("2001:db8::2"))
+        }
+
+        val registrationRecord1 = NsdRegistrationRecord()
+        val registrationRecord2 = NsdRegistrationRecord()
+        val registrationRecord3 = NsdRegistrationRecord()
+
+        val discoveryRecord = NsdDiscoveryRecord()
+
+        tryTest {
+            registerService(registrationRecord1, si1)
+            registerService(registrationRecord2, si2)
+
+            nsdManager.unregisterService(registrationRecord1)
+            registrationRecord1.expectCallback<ServiceUnregistered>()
+
+            registerService(registrationRecord3, si3)
+
+            nsdManager.discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD,
+                    testNetwork1.network, Executor { it.run() }, discoveryRecord)
+            val discoveredInfo = discoveryRecord.waitForServiceDiscovered(
+                    serviceName, serviceType, testNetwork1.network)
+            val resolvedInfo = resolveService(discoveredInfo)
+
+            assertEquals(serviceName, discoveredInfo.serviceName)
+            assertEquals(TEST_PORT, resolvedInfo.port)
+            assertEquals(customHostname, resolvedInfo.hostname)
+            assertAddressEquals(
+                    listOf(parseNumericAddress("192.0.2.24"), parseNumericAddress("2001:db8::2")),
+                    resolvedInfo.hostAddresses)
+        } cleanupStep {
+            nsdManager.stopServiceDiscovery(discoveryRecord)
+            discoveryRecord.expectCallbackEventually<DiscoveryStopped>()
+        } cleanup {
+            nsdManager.unregisterService(registrationRecord2)
+            nsdManager.unregisterService(registrationRecord3)
+        }
+    }
+
+    @Test
+    fun testServiceTypeClientRemovedAfterSocketDestroyed() {
+        val si = makeTestServiceInfo(testNetwork1.network)
+        // Register service on testNetwork1
+        val registrationRecord = NsdRegistrationRecord()
+        registerService(registrationRecord, si)
+        // Register multiple discovery requests.
+        val discoveryRecord1 = NsdDiscoveryRecord()
+        val discoveryRecord2 = NsdDiscoveryRecord()
+        val discoveryRecord3 = NsdDiscoveryRecord()
+        nsdManager.discoverServices("_test1._tcp", NsdManager.PROTOCOL_DNS_SD,
+                testNetwork1.network, { it.run() }, discoveryRecord1)
+        nsdManager.discoverServices("_test2._tcp", NsdManager.PROTOCOL_DNS_SD,
+                testNetwork1.network, { it.run() }, discoveryRecord2)
+        nsdManager.discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD, discoveryRecord3)
+
+        tryTest {
+            discoveryRecord1.expectCallback<DiscoveryStarted>()
+            discoveryRecord2.expectCallback<DiscoveryStarted>()
+            discoveryRecord3.expectCallback<DiscoveryStarted>()
+            val foundInfo = discoveryRecord3.waitForServiceDiscovered(
+                    serviceName, serviceType, testNetwork1.network)
+            assertEquals(testNetwork1.network, foundInfo.network)
+            // Verify that associated ServiceTypeClients has been created for testNetwork1.
+            assertTrue("No serviceTypeClients for testNetwork1.",
+                    hasServiceTypeClientsForNetwork(
+                            getServiceTypeClients(), testNetwork1.network))
+
+            // Disconnect testNetwork1
+            runAsShell(MANAGE_TEST_NETWORKS) {
+                testNetwork1.close(cm)
+            }
+
+            // Verify that no ServiceTypeClients for testNetwork1.
+            discoveryRecord3.expectCallback<ServiceLost>()
+            assertFalse("Still has serviceTypeClients for testNetwork1.",
+                    hasServiceTypeClientsForNetwork(
+                            getServiceTypeClients(), testNetwork1.network))
+        } cleanupStep {
+            nsdManager.stopServiceDiscovery(discoveryRecord1)
+            nsdManager.stopServiceDiscovery(discoveryRecord2)
+            nsdManager.stopServiceDiscovery(discoveryRecord3)
+            discoveryRecord1.expectCallback<DiscoveryStopped>()
+            discoveryRecord2.expectCallback<DiscoveryStopped>()
+            discoveryRecord3.expectCallback<DiscoveryStopped>()
+        } cleanup {
+            nsdManager.unregisterService(registrationRecord)
+            registrationRecord.expectCallback<ServiceUnregistered>()
+        }
+    }
+
+    private fun hasServiceTypeClientsForNetwork(clients: List<String>, network: Network): Boolean {
+        return clients.any { client -> client.substring(
+                client.indexOf("network=") + "network=".length,
+                client.indexOf("interfaceIndex=") - 1) == network.getNetId().toString()
+        }
+    }
+
+    /**
+     * Get ServiceTypeClient logs from the system dump servicediscovery section.
+     *
+     * The sample output:
+     *     ServiceTypeClient: Type{_nmt079019787._tcp.local} \
+     *         SocketKey{ network=116 interfaceIndex=68 } with 1 listeners.
+     *     ServiceTypeClient: Type{_nmt079019787._tcp.local} \
+     *         SocketKey{ network=115 interfaceIndex=67 } with 1 listeners.
+     */
+    private fun getServiceTypeClients(): List<String> {
+        return SystemUtil.runShellCommand(
+                InstrumentationRegistry.getInstrumentation(), "dumpsys servicediscovery")
+                .split("\n").mapNotNull { line ->
+                    line.indexOf("ServiceTypeClient:").let { idx ->
+                        if (idx == -1) null
+                        else line.substring(idx)
+                    }
+                }
+    }
+
     private fun buildConflictingAnnouncement(): ByteBuffer {
         /*
         Generated with:
@@ -2270,4 +2507,4 @@
     // No duplicate addresses in the actual address list
     assertEquals(actual.toSet().size, actual.size)
     assertEquals(expected.toSet(), actual.toSet())
-}
\ No newline at end of file
+}
diff --git a/tests/cts/net/util/java/android/net/cts/util/CtsNetUtils.java b/tests/cts/net/util/java/android/net/cts/util/CtsNetUtils.java
index 3d828a4..670889f 100644
--- a/tests/cts/net/util/java/android/net/cts/util/CtsNetUtils.java
+++ b/tests/cts/net/util/java/android/net/cts/util/CtsNetUtils.java
@@ -408,40 +408,10 @@
         return network;
     }
 
-    public Network connectToCell() throws InterruptedException {
-        if (cellConnectAttempted()) {
-            mCm.unregisterNetworkCallback(mCellNetworkCallback);
-        }
-        NetworkRequest cellRequest = new NetworkRequest.Builder()
-                .addTransportType(TRANSPORT_CELLULAR)
-                .addCapability(NET_CAPABILITY_INTERNET)
-                .build();
-        mCellNetworkCallback = new TestNetworkCallback();
-        mCm.requestNetwork(cellRequest, mCellNetworkCallback);
-        final Network cellNetwork = mCellNetworkCallback.waitForAvailable();
-        assertNotNull("Cell network not available. " +
-                "Please ensure the device has working mobile data.", cellNetwork);
-        return cellNetwork;
-    }
-
-    public void disconnectFromCell() {
-        if (!cellConnectAttempted()) {
-            throw new IllegalStateException("Cell connection not attempted");
-        }
-        mCm.unregisterNetworkCallback(mCellNetworkCallback);
-        mCellNetworkCallback = null;
-    }
-
     public boolean cellConnectAttempted() {
         return mCellNetworkCallback != null;
     }
 
-    public void tearDown() {
-        if (cellConnectAttempted()) {
-            disconnectFromCell();
-        }
-    }
-
     private NetworkRequest makeWifiNetworkRequest() {
         return new NetworkRequest.Builder()
                 .addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
diff --git a/tests/cts/tethering/Android.bp b/tests/cts/tethering/Android.bp
index 3928961..1023173 100644
--- a/tests/cts/tethering/Android.bp
+++ b/tests/cts/tethering/Android.bp
@@ -47,6 +47,7 @@
 
     // Change to system current when TetheringManager move to bootclass path.
     platform_apis: true,
+    min_sdk_version: "30",
     host_required: ["net-tests-utils-host-common"],
 }
 
@@ -80,8 +81,8 @@
 
 // Tethering CTS tests for development and release. These tests always target the platform SDK
 // version, and are subject to all the restrictions appropriate to that version. Before SDK
-// finalization, these tests have a min_sdk_version of 10000, and cannot be installed on release
-// devices.
+// finalization, these tests have a min_sdk_version of 10000, but they can still be installed on
+// release devices as their min_sdk_version is set to a production version.
 android_test {
     name: "CtsTetheringTest",
     defaults: ["CtsTetheringTestDefaults"],
@@ -93,6 +94,14 @@
     // Tag this module as a cts test artifact
     test_suites: [
         "cts",
+        "mts-dnsresolver",
+        "mts-networking",
+        "mts-tethering",
+        "mts-wifi",
+        "mcts-dnsresolver",
+        "mcts-networking",
+        "mcts-tethering",
+        "mcts-wifi",
         "general-tests",
     ],
 
diff --git a/tests/integration/AndroidManifest.xml b/tests/integration/AndroidManifest.xml
index cea83c7..1821329 100644
--- a/tests/integration/AndroidManifest.xml
+++ b/tests/integration/AndroidManifest.xml
@@ -42,6 +42,9 @@
     <uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"/>
     <!-- Register UidFrozenStateChangedCallback -->
     <uses-permission android:name="android.permission.PACKAGE_USAGE_STATS"/>
+    <!-- Permission required for CTS test - NetworkStatsIntegrationTest -->
+    <uses-permission android:name="android.permission.INTERNET"/>
+    <uses-permission android:name="android.permission.CONNECTIVITY_USE_RESTRICTED_NETWORKS"/>
     <application android:debuggable="true">
         <uses-library android:name="android.test.runner"/>
 
diff --git a/tests/integration/src/com/android/server/net/integrationtests/ConnectivityServiceIntegrationTest.kt b/tests/integration/src/com/android/server/net/integrationtests/ConnectivityServiceIntegrationTest.kt
index 361d68c..d2e46af 100644
--- a/tests/integration/src/com/android/server/net/integrationtests/ConnectivityServiceIntegrationTest.kt
+++ b/tests/integration/src/com/android/server/net/integrationtests/ConnectivityServiceIntegrationTest.kt
@@ -66,6 +66,8 @@
 import com.android.testutils.RecorderCallback.CallbackEntry.LinkPropertiesChanged
 import com.android.testutils.TestableNetworkCallback
 import com.android.testutils.tryTest
+import java.util.function.BiConsumer
+import java.util.function.Consumer
 import kotlin.test.assertEquals
 import kotlin.test.assertNotNull
 import kotlin.test.assertTrue
@@ -87,8 +89,6 @@
 import org.mockito.Mockito.mock
 import org.mockito.MockitoAnnotations
 import org.mockito.Spy
-import java.util.function.Consumer
-import java.util.function.BiConsumer
 
 const val SERVICE_BIND_TIMEOUT_MS = 5_000L
 const val TEST_TIMEOUT_MS = 10_000L
@@ -225,6 +225,7 @@
         override fun getSystemProperties() = mock(MockableSystemProperties::class.java)
         override fun makeNetIdManager() = TestNetIdManager()
         override fun getBpfNetMaps(context: Context?, netd: INetd?) = mock(BpfNetMaps::class.java)
+        override fun isChangeEnabled(changeId: Long, uid: Int) = true
 
         override fun makeMultinetworkPolicyTracker(
             c: Context,
@@ -242,18 +243,19 @@
             super.makeHandlerThread(tag).also { handlerThreads.add(it) }
 
         override fun makeCarrierPrivilegeAuthenticator(
-            context: Context,
-            tm: TelephonyManager,
-            requestRestrictedWifiEnabled: Boolean,
-            listener: BiConsumer<Int, Int>
+                context: Context,
+                tm: TelephonyManager,
+                requestRestrictedWifiEnabled: Boolean,
+                listener: BiConsumer<Int, Int>,
+                handler: Handler
         ): CarrierPrivilegeAuthenticator {
             return CarrierPrivilegeAuthenticator(context,
-                object : CarrierPrivilegeAuthenticator.Dependencies() {
-                    override fun makeHandlerThread(): HandlerThread =
-                        super.makeHandlerThread().also { handlerThreads.add(it) }
-                },
-                tm, TelephonyManagerShimImpl.newInstance(tm),
-                requestRestrictedWifiEnabled, listener)
+                    object : CarrierPrivilegeAuthenticator.Dependencies() {
+                        override fun makeHandlerThread(): HandlerThread =
+                                super.makeHandlerThread().also { handlerThreads.add(it) }
+                    },
+                    tm, TelephonyManagerShimImpl.newInstance(tm),
+                    requestRestrictedWifiEnabled, listener, handler)
         }
 
         override fun makeSatelliteAccessController(
diff --git a/tests/integration/src/com/android/server/net/integrationtests/NetworkStackInstrumentationService.kt b/tests/integration/src/com/android/server/net/integrationtests/NetworkStackInstrumentationService.kt
index 104d063..3d948ba 100644
--- a/tests/integration/src/com/android/server/net/integrationtests/NetworkStackInstrumentationService.kt
+++ b/tests/integration/src/com/android/server/net/integrationtests/NetworkStackInstrumentationService.kt
@@ -18,10 +18,14 @@
 
 import android.app.Service
 import android.content.Intent
+import androidx.annotation.GuardedBy
+import com.android.testutils.quitExecutorServices
+import com.android.testutils.quitThreads
 import java.net.URL
 import java.util.Collections
 import java.util.concurrent.ConcurrentHashMap
 import java.util.concurrent.ConcurrentLinkedQueue
+import java.util.concurrent.ExecutorService
 import kotlin.collections.ArrayList
 import kotlin.test.fail
 
@@ -37,7 +41,12 @@
                 .run {
                     withDefault { key -> getOrPut(key) { ConcurrentLinkedQueue() } }
                 }
-        private val httpRequestUrls = Collections.synchronizedList(ArrayList<String>())
+        private val httpRequestUrls = Collections.synchronizedList(mutableListOf<String>())
+
+        @GuardedBy("networkMonitorThreads")
+        private val networkMonitorThreads = mutableListOf<Thread>()
+        @GuardedBy("networkMonitorExecutorServices")
+        private val networkMonitorExecutorServices = mutableListOf<ExecutorService>()
 
         /**
          * Called when an HTTP request is being processed by NetworkMonitor. Returns the response
@@ -52,10 +61,47 @@
         }
 
         /**
+         * Called when NetworkMonitor creates a new Thread.
+         */
+        fun onNetworkMonitorThreadCreated(thread: Thread) {
+            synchronized(networkMonitorThreads) {
+                networkMonitorThreads.add(thread)
+            }
+        }
+
+        /**
+         * Called when NetworkMonitor creates a new ExecutorService.
+         */
+        fun onNetworkMonitorExecutorServiceCreated(executorService: ExecutorService) {
+            synchronized(networkMonitorExecutorServices) {
+                networkMonitorExecutorServices.add(executorService)
+            }
+        }
+
+        /**
          * Clear all state of this connector. This is intended for use between two tests, so all
          * state should be reset as if the connector was just created.
          */
         override fun clearAllState() {
+            quitThreads(
+                maxRetryCount = 3,
+                interrupt = true) {
+                synchronized(networkMonitorThreads) {
+                    networkMonitorThreads.toList().also { networkMonitorThreads.clear() }
+                }
+            }
+            quitExecutorServices(
+                maxRetryCount = 3,
+                // NetworkMonitor is expected to have interrupted its executors when probing
+                // finishes, otherwise it's a thread pool leak that should be caught, so they should
+                // not need to be interrupted (the test only needs to wait for them to finish).
+                interrupt = false) {
+                synchronized(networkMonitorExecutorServices) {
+                    networkMonitorExecutorServices.toList().also {
+                        networkMonitorExecutorServices.clear()
+                    }
+                }
+            }
             httpResponses.clear()
             httpRequestUrls.clear()
         }
diff --git a/tests/integration/src/com/android/server/net/integrationtests/NetworkStatsIntegrationTest.kt b/tests/integration/src/com/android/server/net/integrationtests/NetworkStatsIntegrationTest.kt
new file mode 100644
index 0000000..52e502d
--- /dev/null
+++ b/tests/integration/src/com/android/server/net/integrationtests/NetworkStatsIntegrationTest.kt
@@ -0,0 +1,617 @@
+/*
+ * 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.net.integrationtests
+
+import android.Manifest.permission.MANAGE_TEST_NETWORKS
+import android.annotation.TargetApi
+import android.app.usage.NetworkStats
+import android.app.usage.NetworkStats.Bucket
+import android.app.usage.NetworkStats.Bucket.TAG_NONE
+import android.app.usage.NetworkStatsManager
+import android.content.Context
+import android.net.ConnectivityManager
+import android.net.ConnectivityManager.TYPE_TEST
+import android.net.InetAddresses
+import android.net.IpPrefix
+import android.net.LinkAddress
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.net.NetworkRequest
+import android.net.NetworkTemplate
+import android.net.NetworkTemplate.MATCH_TEST
+import android.net.TestNetworkSpecifier
+import android.net.TrafficStats
+import android.os.Build
+import android.os.Process
+import androidx.test.platform.app.InstrumentationRegistry
+import com.android.server.net.integrationtests.NetworkStatsIntegrationTest.Direction.DOWNLOAD
+import com.android.server.net.integrationtests.NetworkStatsIntegrationTest.Direction.UPLOAD
+import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo
+import com.android.testutils.DevSdkIgnoreRunner
+import com.android.testutils.PacketBridge
+import com.android.testutils.RecorderCallback.CallbackEntry.LinkPropertiesChanged
+import com.android.testutils.TestDnsServer
+import com.android.testutils.TestHttpServer
+import com.android.testutils.TestableNetworkCallback
+import com.android.testutils.runAsShell
+import fi.iki.elonen.NanoHTTPD
+import java.io.BufferedInputStream
+import java.io.BufferedOutputStream
+import java.net.HttpURLConnection
+import java.net.HttpURLConnection.HTTP_OK
+import java.net.InetSocketAddress
+import java.net.URL
+import java.nio.charset.Charset
+import kotlin.math.ceil
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+import org.junit.After
+import org.junit.Assume.assumeTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+private const val TEST_TAG = 0xF00D
+
+@RunWith(DevSdkIgnoreRunner::class)
+@TargetApi(Build.VERSION_CODES.S)
+@IgnoreUpTo(Build.VERSION_CODES.TIRAMISU)
+class NetworkStatsIntegrationTest {
+    private val TAG = NetworkStatsIntegrationTest::class.java.simpleName
+    private val LOCAL_V6ADDR =
+        LinkAddress(InetAddresses.parseNumericAddress("2001:db8::1234"), 64)
+
+    // Remote address, both the client and server will have a hallucination that
+    // they are talking to this address.
+    private val REMOTE_V6ADDR =
+        LinkAddress(InetAddresses.parseNumericAddress("dead:beef::808:808"), 64)
+    private val REMOTE_V4ADDR =
+        LinkAddress(InetAddresses.parseNumericAddress("8.8.8.8"), 32)
+    private val DEFAULT_MTU = 1500
+    private val DEFAULT_BUFFER_SIZE = 1500 // Any size greater than or equal to mtu
+    private val CONNECTION_TIMEOUT_MILLIS = 15000
+    private val TEST_DOWNLOAD_SIZE = 10000L
+    private val TEST_UPLOAD_SIZE = 20000L
+    private val HTTP_SERVER_NAME = "test.com"
+    private val HTTP_SERVER_PORT = 8080 // Use port > 1024 to avoid restrictions on system ports
+    private val DNS_INTERNAL_SERVER_PORT = 53
+    private val DNS_EXTERNAL_SERVER_PORT = 1053
+    private val TCP_ACK_SIZE = 72
+
+    // Packet overheads that are not part of the actual data transmission, these
+    // include DNS packets, TCP handshake/termination packets, and HTTP header
+    // packets. These overheads were gathered from real samples and may not
+    // be perfectly accurate because of DNS caches and TCP retransmissions, etc.
+    private val CONSTANT_PACKET_OVERHEAD = 8
+
+    // 130 is an observed average.
+    private val CONSTANT_BYTES_OVERHEAD = 130 * CONSTANT_PACKET_OVERHEAD
+    private val TOLERANCE = 1.3
+
+    // Set up the packet bridge with two IPv6 address only test networks.
+    private val inst = InstrumentationRegistry.getInstrumentation()
+    private val context = inst.getContext()
+    private val packetBridge = runAsShell(MANAGE_TEST_NETWORKS) {
+        PacketBridge(
+            context,
+            listOf(LOCAL_V6ADDR),
+            REMOTE_V6ADDR.address,
+            listOf(
+                Pair(DNS_INTERNAL_SERVER_PORT, DNS_EXTERNAL_SERVER_PORT)
+            )
+        )
+    }
+    private val cm = context.getSystemService(ConnectivityManager::class.java)!!
+
+    // Set up DNS server for testing server and DNS64.
+    private val fakeDns = TestDnsServer(
+        packetBridge.externalNetwork,
+        InetSocketAddress(LOCAL_V6ADDR.address, DNS_EXTERNAL_SERVER_PORT)
+    ).apply {
+        start()
+        setAnswer(
+            "ipv4only.arpa",
+            listOf(IpPrefix(REMOTE_V6ADDR.address, REMOTE_V6ADDR.prefixLength).address)
+        )
+        setAnswer(HTTP_SERVER_NAME, listOf(REMOTE_V4ADDR.address))
+    }
+
+    // Start up test http server.
+    private val httpServer = TestHttpServer(
+        LOCAL_V6ADDR.address.hostAddress,
+        HTTP_SERVER_PORT
+    ).apply {
+        start()
+    }
+
+    @Before
+    fun setUp() {
+        assumeTrue(shouldRunTests())
+        packetBridge.start()
+    }
+
+    // For networkstack tests, it is not guaranteed that the tethering module will be
+    // updated at the same time. If the tethering module is not new enough, it may not contain
+    // the necessary abilities to run these tests. For example, The tests depends on test
+    // network stats being counted, which can only be achieved when they are marked as TYPE_TEST.
+    // If the tethering module does not support TYPE_TEST stats, then these tests will need
+    // to be skipped.
+    fun shouldRunTests() = cm.getNetworkInfo(packetBridge.internalNetwork)!!.type == TYPE_TEST
+
+    @After
+    fun tearDown() {
+        packetBridge.stop()
+        fakeDns.stop()
+        httpServer.stop()
+    }
+
+    private fun waitFor464XlatReady(network: Network): String {
+        val iface = cm.getLinkProperties(network)!!.interfaceName!!
+
+        // Make a network request to listen to the specific test network.
+        val nr = NetworkRequest.Builder()
+            .clearCapabilities()
+            .addTransportType(NetworkCapabilities.TRANSPORT_TEST)
+            .setNetworkSpecifier(TestNetworkSpecifier(iface))
+            .build()
+        val testCb = TestableNetworkCallback()
+        cm.registerNetworkCallback(nr, testCb)
+
+        // Wait for the stacked address to be available.
+        testCb.eventuallyExpect<LinkPropertiesChanged> {
+            it.lp.stackedLinks.getOrNull(0)?.linkAddresses?.getOrNull(0) != null
+        }
+
+        return iface
+    }
+
+    private val Network.mtu: Int get() {
+        val lp = cm.getLinkProperties(this)!!
+        val mtuStacked = if (lp.stackedLinks[0]?.mtu != 0) lp.stackedLinks[0].mtu else DEFAULT_MTU
+        val mtuInterface = if (lp.mtu != 0) lp.mtu else DEFAULT_MTU
+        return mtuInterface.coerceAtMost(mtuStacked)
+    }
+
+    /**
+     * Verify data usage download stats with test 464xlat networks.
+     *
+     * This test starts two test networks and binds them together, the internal one is for the
+     * client to make http traffic on the test network, and the external one is for the mocked
+     * http and dns server to bind to and provide responses.
+     *
+     * After Clat setup, the client will use clat v4 address to send packets to the mocked
+     * server v4 address, which will be translated into a v6 packet by the clat daemon with
+     * NAT64 prefix learned from the mocked DNS64 response. And send to the interface.
+     *
+     * While the packets are being forwarded to the external interface, the servers will see
+     * the packets originated from the mocked v6 address, and destined to a local v6 address.
+     */
+    @Test
+    fun test464XlatTcpStats() {
+        // Wait for 464Xlat to be ready.
+        val internalInterfaceName = waitFor464XlatReady(packetBridge.internalNetwork)
+        val mtu = packetBridge.internalNetwork.mtu
+
+        val snapshotBeforeTest = StatsSnapshot(context, internalInterfaceName)
+
+        // Generate the download traffic.
+        genHttpTraffic(packetBridge.internalNetwork, uploadSize = 0L, TEST_DOWNLOAD_SIZE)
+
+        // In practice, for one way 10k download payload, the download usage is about
+        // 11222~12880 bytes, with 14~17 packets. And the upload usage is about 1279~1626 bytes
+        // with 14~17 packets, which is majorly contributed by TCP ACK packets.
+        val snapshotAfterDownload = StatsSnapshot(context, internalInterfaceName)
+        val (expectedDownloadLower, expectedDownloadUpper) = getExpectedStatsBounds(
+            TEST_DOWNLOAD_SIZE,
+            mtu,
+            DOWNLOAD
+        )
+        assertOnlyNonTaggedStatsIncreases(
+            snapshotBeforeTest,
+            snapshotAfterDownload,
+            expectedDownloadLower,
+            expectedDownloadUpper
+        )
+
+        // Generate upload traffic with tag to verify tagged data accounting as well.
+        genHttpTrafficWithTag(
+            packetBridge.internalNetwork,
+            TEST_UPLOAD_SIZE,
+            downloadSize = 0L,
+            TEST_TAG
+        )
+
+        // Verify upload data usage accounting.
+        val snapshotAfterUpload = StatsSnapshot(context, internalInterfaceName)
+        val (expectedUploadLower, expectedUploadUpper) = getExpectedStatsBounds(
+            TEST_UPLOAD_SIZE,
+            mtu,
+            UPLOAD
+        )
+        assertAllStatsIncreases(
+            snapshotAfterDownload,
+            snapshotAfterUpload,
+            expectedUploadLower,
+            expectedUploadUpper
+        )
+    }
+
+    private enum class Direction {
+        DOWNLOAD,
+        UPLOAD
+    }
+
+    private fun getExpectedStatsBounds(
+        transmittedSize: Long,
+        mtu: Int,
+        direction: Direction
+    ): Pair<BareStats, BareStats> {
+        // This is already an underestimated value since the input doesn't include TCP/IP
+        // layer overhead.
+        val txBytesLower = transmittedSize
+        // Include TCP/IP header overheads and retransmissions in the upper bound.
+        val txBytesUpper = (transmittedSize * TOLERANCE).toLong()
+        val txPacketsLower = txBytesLower / mtu + (CONSTANT_PACKET_OVERHEAD / TOLERANCE).toLong()
+        val estTransmissionPacketsUpper = ceil(txBytesUpper / mtu.toDouble()).toLong()
+        val txPacketsUpper = estTransmissionPacketsUpper +
+                (CONSTANT_PACKET_OVERHEAD * TOLERANCE).toLong()
+        // Assume ACK only sent once for the entire transmission.
+        val rxPacketsLower = 1L + (CONSTANT_PACKET_OVERHEAD / TOLERANCE).toLong()
+        // Assume ACK sent for every RX packet.
+        val rxPacketsUpper = txPacketsUpper
+        val rxBytesLower = 1L * TCP_ACK_SIZE + (CONSTANT_BYTES_OVERHEAD / TOLERANCE).toLong()
+        val rxBytesUpper = estTransmissionPacketsUpper * TCP_ACK_SIZE +
+                (CONSTANT_BYTES_OVERHEAD * TOLERANCE).toLong()
+
+        return if (direction == UPLOAD) {
+            BareStats(rxBytesLower, rxPacketsLower, txBytesLower, txPacketsLower) to
+                    BareStats(rxBytesUpper, rxPacketsUpper, txBytesUpper, txPacketsUpper)
+        } else {
+            BareStats(txBytesLower, txPacketsLower, rxBytesLower, rxPacketsLower) to
+                    BareStats(txBytesUpper, txPacketsUpper, rxBytesUpper, rxPacketsUpper)
+        }
+    }
+
+    private fun genHttpTraffic(network: Network, uploadSize: Long, downloadSize: Long) =
+        genHttpTrafficWithTag(network, uploadSize, downloadSize, NetworkStats.Bucket.TAG_NONE)
+
+    private fun genHttpTrafficWithTag(
+        network: Network,
+        uploadSize: Long,
+        downloadSize: Long,
+        tag: Int
+    ) {
+        val path = "/test_upload_download"
+        val buf = ByteArray(DEFAULT_BUFFER_SIZE)
+
+        httpServer.addResponse(
+            TestHttpServer.Request(path, NanoHTTPD.Method.POST),
+            NanoHTTPD.Response.Status.OK,
+            content = getRandomString(downloadSize)
+        )
+        var httpConnection: HttpURLConnection? = null
+        try {
+            TrafficStats.setThreadStatsTag(tag)
+            val spec = "http://$HTTP_SERVER_NAME:${httpServer.listeningPort}$path"
+            val url = URL(spec)
+            httpConnection = network.openConnection(url) as HttpURLConnection
+            httpConnection.connectTimeout = CONNECTION_TIMEOUT_MILLIS
+            httpConnection.requestMethod = "POST"
+            httpConnection.doOutput = true
+            // Tell the server that the response should not be compressed. Otherwise, the data usage
+            // accounted will be less than expected.
+            httpConnection.setRequestProperty("Accept-Encoding", "identity")
+            // Tell the server that to close connection after this request, this is needed to
+            // prevent from reusing the same socket that has different tagging requirement.
+            httpConnection.setRequestProperty("Connection", "close")
+
+            // Send http body.
+            val outputStream = BufferedOutputStream(httpConnection.outputStream)
+            outputStream.write(getRandomString(uploadSize).toByteArray(Charset.forName("UTF-8")))
+            outputStream.close()
+            assertEquals(HTTP_OK, httpConnection.responseCode)
+
+            // Receive response from the server.
+            val inputStream = BufferedInputStream(httpConnection.getInputStream())
+            var total = 0L
+            while (true) {
+                val count = inputStream.read(buf)
+                if (count == -1) break // End-of-Stream
+                total += count
+            }
+            assertEquals(downloadSize, total)
+        } finally {
+            httpConnection?.inputStream?.close()
+            TrafficStats.clearThreadStatsTag()
+        }
+    }
+
+    // NetworkStats.Bucket cannot be written. So another class is needed to
+    // perform arithmetic operations.
+    data class BareStats(
+        val rxBytes: Long,
+        val rxPackets: Long,
+        val txBytes: Long,
+        val txPackets: Long
+    ) {
+        operator fun plus(other: BareStats): BareStats {
+            return BareStats(
+                this.rxBytes + other.rxBytes,
+                this.rxPackets + other.rxPackets,
+                this.txBytes + other.txBytes,
+                this.txPackets + other.txPackets
+            )
+        }
+
+        operator fun minus(other: BareStats): BareStats {
+            return BareStats(
+                this.rxBytes - other.rxBytes,
+                this.rxPackets - other.rxPackets,
+                this.txBytes - other.txBytes,
+                this.txPackets - other.txPackets
+            )
+        }
+
+        fun reverse(): BareStats =
+            BareStats(
+                rxBytes = txBytes,
+                rxPackets = txPackets,
+                txBytes = rxBytes,
+                txPackets = rxPackets
+            )
+
+        override fun toString(): String {
+            return "BareStats{rx/txBytes=$rxBytes/$txBytes, rx/txPackets=$rxPackets/$txPackets}"
+        }
+
+        override fun equals(other: Any?): Boolean {
+            if (this === other) return true
+            if (other !is BareStats) return false
+
+            if (rxBytes != other.rxBytes) return false
+            if (rxPackets != other.rxPackets) return false
+            if (txBytes != other.txBytes) return false
+            if (txPackets != other.txPackets) return false
+
+            return true
+        }
+
+        override fun hashCode(): Int {
+            return (rxBytes * 11 + rxPackets * 13 + txBytes * 17 + txPackets * 19).toInt()
+        }
+
+        companion object {
+            val EMPTY = BareStats(0L, 0L, 0L, 0L)
+        }
+    }
+
+    data class StatsSnapshot(val context: Context, val iface: String) {
+        val statsSummary = getNetworkSummary(iface)
+        val statsUid = getUidDetail(iface, TAG_NONE)
+        val taggedSummary = getTaggedNetworkSummary(iface, TEST_TAG)
+        val taggedUid = getUidDetail(iface, TEST_TAG)
+        val trafficStatsIface = getTrafficStatsIface(iface)
+        val trafficStatsUid = getTrafficStatsUid(Process.myUid())
+
+        private fun getUidDetail(iface: String, tag: Int): BareStats {
+            return getNetworkStatsThat(iface, tag) { nsm, template ->
+                nsm.queryDetailsForUidTagState(
+                    template,
+                    Long.MIN_VALUE,
+                    Long.MAX_VALUE,
+                    Process.myUid(),
+                    tag,
+                    Bucket.STATE_ALL
+                )
+            }
+        }
+
+        private fun getNetworkSummary(iface: String): BareStats {
+            return getNetworkStatsThat(iface, TAG_NONE) { nsm, template ->
+                nsm.querySummary(template, Long.MIN_VALUE, Long.MAX_VALUE)
+            }
+        }
+
+        private fun getTaggedNetworkSummary(iface: String, tag: Int): BareStats {
+            return getNetworkStatsThat(iface, tag) { nsm, template ->
+                nsm.queryTaggedSummary(template, Long.MIN_VALUE, Long.MAX_VALUE)
+            }
+        }
+
+        private fun getNetworkStatsThat(
+            iface: String,
+            tag: Int,
+            queryApi: (nsm: NetworkStatsManager, template: NetworkTemplate) -> NetworkStats
+        ): BareStats {
+            val nsm = context.getSystemService(NetworkStatsManager::class.java)!!
+            nsm.forceUpdate()
+            val testTemplate = NetworkTemplate.Builder(MATCH_TEST)
+                .setWifiNetworkKeys(setOf(iface)).build()
+            val stats = queryApi.invoke(nsm, testTemplate)
+            val filteredBuckets =
+                stats.buckets().filter { it.uid == Process.myUid() && it.tag == tag }
+            return filteredBuckets.fold(BareStats.EMPTY) { acc, it ->
+                acc + BareStats(
+                    it.rxBytes,
+                    it.rxPackets,
+                    it.txBytes,
+                    it.txPackets
+                )
+            }
+        }
+
+        // Helper function to iterate buckets in app.usage.NetworkStats.
+        private fun NetworkStats.buckets() = object : Iterable<NetworkStats.Bucket> {
+            override fun iterator() = object : Iterator<NetworkStats.Bucket> {
+                override operator fun hasNext() = hasNextBucket()
+                override operator fun next() =
+                    NetworkStats.Bucket().also { assertTrue(getNextBucket(it)) }
+            }
+        }
+
+        private fun getTrafficStatsIface(iface: String): BareStats = BareStats(
+            TrafficStats.getRxBytes(iface),
+            TrafficStats.getRxPackets(iface),
+            TrafficStats.getTxBytes(iface),
+            TrafficStats.getTxPackets(iface)
+        )
+
+        private fun getTrafficStatsUid(uid: Int): BareStats = BareStats(
+            TrafficStats.getUidRxBytes(uid),
+            TrafficStats.getUidRxPackets(uid),
+            TrafficStats.getUidTxBytes(uid),
+            TrafficStats.getUidTxPackets(uid)
+        )
+    }
+
+    private fun assertAllStatsIncreases(
+        before: StatsSnapshot,
+        after: StatsSnapshot,
+        lower: BareStats,
+        upper: BareStats
+    ) {
+        assertNonTaggedStatsIncreases(before, after, lower, upper)
+        assertTaggedStatsIncreases(before, after, lower, upper)
+    }
+
+    private fun assertOnlyNonTaggedStatsIncreases(
+        before: StatsSnapshot,
+        after: StatsSnapshot,
+        lower: BareStats,
+        upper: BareStats
+    ) {
+        assertNonTaggedStatsIncreases(before, after, lower, upper)
+        assertTaggedStatsEquals(before, after)
+    }
+
+    private fun assertNonTaggedStatsIncreases(
+        before: StatsSnapshot,
+        after: StatsSnapshot,
+        lower: BareStats,
+        upper: BareStats
+    ) {
+        assertInRange(
+            "Unexpected iface traffic stats",
+            after.iface,
+            before.trafficStatsIface,
+            after.trafficStatsIface,
+            lower,
+            upper
+        )
+        // Uid traffic stats are counted in both direction because the external network
+        // traffic is also attributed to the test uid.
+        assertInRange(
+            "Unexpected uid traffic stats",
+            after.iface,
+            before.trafficStatsUid,
+            after.trafficStatsUid,
+            lower + lower.reverse(),
+            upper + upper.reverse()
+        )
+        assertInRange(
+            "Unexpected non-tagged summary stats",
+            after.iface,
+            before.statsSummary,
+            after.statsSummary,
+            lower,
+            upper
+        )
+        assertInRange(
+            "Unexpected non-tagged uid stats",
+            after.iface,
+            before.statsUid,
+            after.statsUid,
+            lower,
+            upper
+        )
+    }
+
+    private fun assertTaggedStatsEquals(before: StatsSnapshot, after: StatsSnapshot) {
+        // Increment of tagged data should be zero since no tagged traffic was generated.
+        assertEquals(
+            before.taggedSummary,
+            after.taggedSummary,
+            "Unexpected tagged summary stats: ${after.iface}"
+        )
+        assertEquals(
+            before.taggedUid,
+            after.taggedUid,
+            "Unexpected tagged uid stats: ${Process.myUid()} on ${after.iface}"
+        )
+    }
+
+    private fun assertTaggedStatsIncreases(
+        before: StatsSnapshot,
+        after: StatsSnapshot,
+        lower: BareStats,
+        upper: BareStats
+    ) {
+        assertInRange(
+            "Unexpected tagged summary stats",
+            after.iface,
+            before.taggedSummary,
+            after.taggedSummary,
+            lower,
+            upper
+        )
+        assertInRange(
+            "Unexpected tagged uid stats: ${Process.myUid()}",
+            after.iface,
+            before.taggedUid,
+            after.taggedUid,
+            lower,
+            upper
+        )
+    }
+
+    /** Verify the given BareStats is in range [lower, upper] */
+    private fun assertInRange(
+        tag: String,
+        iface: String,
+        before: BareStats,
+        after: BareStats,
+        lower: BareStats,
+        upper: BareStats
+    ) {
+        // Passing the value after operation and the value before operation to dump the actual
+        // numbers if it fails.
+        assertTrue(
+            checkInRange(before, after, lower, upper),
+            "$tag on $iface: $after - $before is not within range [$lower, $upper]"
+        )
+    }
+
+    private fun checkInRange(
+            before: BareStats,
+            after: BareStats,
+            lower: BareStats,
+            upper: BareStats
+    ): Boolean {
+        val value = after - before
+        return value.rxBytes in lower.rxBytes..upper.rxBytes &&
+                value.rxPackets in lower.rxPackets..upper.rxPackets &&
+                value.txBytes in lower.txBytes..upper.txBytes &&
+                value.txPackets in lower.txPackets..upper.txPackets
+    }
+
+    fun getRandomString(length: Long): String {
+        val allowedChars = ('A'..'Z') + ('a'..'z') + ('0'..'9')
+        return (1..length)
+            .map { allowedChars.random() }
+            .joinToString("")
+    }
+}
diff --git a/tests/integration/src/com/android/server/net/integrationtests/ServiceManagerWrapperIntegrationTest.kt b/tests/integration/src/com/android/server/net/integrationtests/ServiceManagerWrapperIntegrationTest.kt
new file mode 100644
index 0000000..7e00ed2
--- /dev/null
+++ b/tests/integration/src/com/android/server/net/integrationtests/ServiceManagerWrapperIntegrationTest.kt
@@ -0,0 +1,38 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.server.net.integrationtests
+
+import android.content.Context
+import android.os.Build
+import com.android.server.ServiceManagerWrapper
+import com.android.testutils.ConnectivityModuleTest
+import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo
+import com.android.testutils.DevSdkIgnoreRunner
+import kotlin.test.assertNotNull
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/** Integration tests for {@link ServiceManagerWrapper}. */
+@RunWith(DevSdkIgnoreRunner::class)
+@IgnoreUpTo(Build.VERSION_CODES.S)
+@ConnectivityModuleTest
+class ServiceManagerWrapperIntegrationTest {
+    @Test
+    fun testWaitForService_successFullyRetrievesConnectivityServiceBinder() {
+        assertNotNull(ServiceManagerWrapper.waitForService(Context.CONNECTIVITY_SERVICE))
+    }
+}
diff --git a/tests/integration/src/com/android/server/net/integrationtests/TestNetworkStackService.kt b/tests/integration/src/com/android/server/net/integrationtests/TestNetworkStackService.kt
index 7e227c4..e43ce29 100644
--- a/tests/integration/src/com/android/server/net/integrationtests/TestNetworkStackService.kt
+++ b/tests/integration/src/com/android/server/net/integrationtests/TestNetworkStackService.kt
@@ -30,13 +30,14 @@
 import com.android.server.NetworkStackService.NetworkStackConnector
 import com.android.server.connectivity.NetworkMonitor
 import com.android.server.net.integrationtests.NetworkStackInstrumentationService.InstrumentationConnector
-import org.mockito.Mockito.doReturn
-import org.mockito.Mockito.mock
-import org.mockito.Mockito.spy
 import java.io.ByteArrayInputStream
 import java.net.HttpURLConnection
 import java.net.URL
 import java.nio.charset.StandardCharsets
+import java.util.concurrent.ExecutorService
+import org.mockito.Mockito.doReturn
+import org.mockito.Mockito.mock
+import org.mockito.Mockito.spy
 
 private const val TEST_NETID = 42
 
@@ -60,6 +61,10 @@
     private class NetworkMonitorDeps(private val privateDnsBypassNetwork: Network) :
             NetworkMonitor.Dependencies() {
         override fun getPrivateDnsBypassNetwork(network: Network?) = privateDnsBypassNetwork
+        override fun onThreadCreated(thread: Thread) =
+            InstrumentationConnector.onNetworkMonitorThreadCreated(thread)
+        override fun onExecutorServiceCreated(ecs: ExecutorService) =
+            InstrumentationConnector.onNetworkMonitorExecutorServiceCreated(ecs)
     }
 
     /**
diff --git a/tests/unit/Android.bp b/tests/unit/Android.bp
index 4a1298f..2f88c41 100644
--- a/tests/unit/Android.bp
+++ b/tests/unit/Android.bp
@@ -58,36 +58,20 @@
 filegroup {
     name: "non-connectivity-module-test",
     srcs: [
-        "java/android/net/Ikev2VpnProfileTest.java",
         "java/android/net/IpMemoryStoreTest.java",
         "java/android/net/TelephonyNetworkSpecifierTest.java",
-        "java/android/net/VpnManagerTest.java",
         "java/android/net/ipmemorystore/*.java",
         "java/android/net/netstats/NetworkStatsDataMigrationUtilsTest.kt",
         "java/com/android/internal/net/NetworkUtilsInternalTest.java",
-        "java/com/android/internal/net/VpnProfileTest.java",
-        "java/com/android/server/VpnManagerServiceTest.java",
         "java/com/android/server/connectivity/IpConnectivityEventBuilderTest.java",
         "java/com/android/server/connectivity/IpConnectivityMetricsTest.java",
         "java/com/android/server/connectivity/MetricsTestUtil.java",
         "java/com/android/server/connectivity/MultipathPolicyTrackerTest.java",
         "java/com/android/server/connectivity/NetdEventListenerServiceTest.java",
-        "java/com/android/server/connectivity/VpnTest.java",
         "java/com/android/server/net/ipmemorystore/*.java",
     ],
 }
 
-// Subset of services-core used to by ConnectivityService tests to test VPN realistically.
-// This is stripped by jarjar (see rules below) from other unrelated classes, so tests do not
-// include most classes from services-core, which are unrelated and cause wrong code coverage
-// calculations.
-java_library {
-    name: "services.core-vpn",
-    static_libs: ["services.core"],
-    jarjar_rules: "vpn-jarjar-rules.txt",
-    visibility: ["//visibility:private"],
-}
-
 java_defaults {
     name: "FrameworksNetTestsDefaults",
     min_sdk_version: "30",
@@ -114,7 +98,6 @@
         "platform-test-annotations",
         "service-connectivity-pre-jarjar",
         "service-connectivity-tiramisu-pre-jarjar",
-        "services.core-vpn",
         "testables",
         "cts-net-utils",
     ],
diff --git a/tests/unit/java/android/net/Ikev2VpnProfileTest.java b/tests/unit/java/android/net/Ikev2VpnProfileTest.java
deleted file mode 100644
index e12e961..0000000
--- a/tests/unit/java/android/net/Ikev2VpnProfileTest.java
+++ /dev/null
@@ -1,585 +0,0 @@
-/*
- * Copyright (C) 2019 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 android.net;
-
-import static android.net.cts.util.IkeSessionTestUtils.CHILD_PARAMS;
-import static android.net.cts.util.IkeSessionTestUtils.IKE_PARAMS_V6;
-import static android.net.cts.util.IkeSessionTestUtils.getTestIkeSessionParams;
-
-import static com.android.testutils.DevSdkIgnoreRuleKt.SC_V2;
-
-import static org.junit.Assert.assertArrayEquals;
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertNotNull;
-import static org.junit.Assert.assertNull;
-import static org.junit.Assert.assertTrue;
-import static org.junit.Assert.fail;
-
-import android.net.ipsec.ike.IkeKeyIdIdentification;
-import android.net.ipsec.ike.IkeTunnelConnectionParams;
-import android.os.Build;
-import android.test.mock.MockContext;
-
-import androidx.test.filters.SmallTest;
-
-import com.android.internal.net.VpnProfile;
-import com.android.internal.org.bouncycastle.x509.X509V1CertificateGenerator;
-import com.android.net.module.util.ProxyUtils;
-import com.android.testutils.DevSdkIgnoreRule;
-import com.android.testutils.DevSdkIgnoreRunner;
-
-import org.junit.Before;
-import org.junit.Rule;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-
-import java.math.BigInteger;
-import java.security.KeyPair;
-import java.security.KeyPairGenerator;
-import java.security.PrivateKey;
-import java.security.cert.X509Certificate;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Date;
-import java.util.List;
-import java.util.concurrent.TimeUnit;
-
-import javax.security.auth.x500.X500Principal;
-
-/** Unit tests for {@link Ikev2VpnProfile.Builder}. */
-@SmallTest
-@RunWith(DevSdkIgnoreRunner.class)
-@DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.R)
-public class Ikev2VpnProfileTest {
-    private static final String SERVER_ADDR_STRING = "1.2.3.4";
-    private static final String IDENTITY_STRING = "Identity";
-    private static final String USERNAME_STRING = "username";
-    private static final String PASSWORD_STRING = "pa55w0rd";
-    private static final String EXCL_LIST = "exclList";
-    private static final byte[] PSK_BYTES = "preSharedKey".getBytes();
-    private static final int TEST_MTU = 1300;
-
-    @Rule
-    public final DevSdkIgnoreRule ignoreRule = new DevSdkIgnoreRule();
-
-    private final MockContext mMockContext =
-            new MockContext() {
-                @Override
-                public String getOpPackageName() {
-                    return "fooPackage";
-                }
-            };
-    private final ProxyInfo mProxy = ProxyInfo.buildDirectProxy(
-            SERVER_ADDR_STRING, -1, ProxyUtils.exclusionStringAsList(EXCL_LIST));
-
-    private X509Certificate mUserCert;
-    private X509Certificate mServerRootCa;
-    private PrivateKey mPrivateKey;
-
-    @Before
-    public void setUp() throws Exception {
-        mServerRootCa = generateRandomCertAndKeyPair().cert;
-
-        final CertificateAndKey userCertKey = generateRandomCertAndKeyPair();
-        mUserCert = userCertKey.cert;
-        mPrivateKey = userCertKey.key;
-    }
-
-    private Ikev2VpnProfile.Builder getBuilderWithDefaultOptions() {
-        final Ikev2VpnProfile.Builder builder =
-                new Ikev2VpnProfile.Builder(SERVER_ADDR_STRING, IDENTITY_STRING);
-
-        builder.setBypassable(true);
-        builder.setProxy(mProxy);
-        builder.setMaxMtu(TEST_MTU);
-        builder.setMetered(true);
-
-        return builder;
-    }
-
-    @Test
-    public void testBuildValidProfileWithOptions() throws Exception {
-        final Ikev2VpnProfile.Builder builder = getBuilderWithDefaultOptions();
-
-        builder.setAuthUsernamePassword(USERNAME_STRING, PASSWORD_STRING, mServerRootCa);
-        final Ikev2VpnProfile profile = builder.build();
-        assertNotNull(profile);
-
-        // Check non-auth parameters correctly stored
-        assertEquals(SERVER_ADDR_STRING, profile.getServerAddr());
-        assertEquals(IDENTITY_STRING, profile.getUserIdentity());
-        assertEquals(mProxy, profile.getProxyInfo());
-        assertTrue(profile.isBypassable());
-        assertTrue(profile.isMetered());
-        assertEquals(TEST_MTU, profile.getMaxMtu());
-        assertEquals(Ikev2VpnProfile.DEFAULT_ALGORITHMS, profile.getAllowedAlgorithms());
-    }
-
-    @Test
-    public void testBuildUsernamePasswordProfile() throws Exception {
-        final Ikev2VpnProfile.Builder builder = getBuilderWithDefaultOptions();
-
-        builder.setAuthUsernamePassword(USERNAME_STRING, PASSWORD_STRING, mServerRootCa);
-        final Ikev2VpnProfile profile = builder.build();
-        assertNotNull(profile);
-
-        assertEquals(USERNAME_STRING, profile.getUsername());
-        assertEquals(PASSWORD_STRING, profile.getPassword());
-        assertEquals(mServerRootCa, profile.getServerRootCaCert());
-
-        assertNull(profile.getPresharedKey());
-        assertNull(profile.getRsaPrivateKey());
-        assertNull(profile.getUserCert());
-    }
-
-    @Test
-    public void testBuildDigitalSignatureProfile() throws Exception {
-        final Ikev2VpnProfile.Builder builder = getBuilderWithDefaultOptions();
-
-        builder.setAuthDigitalSignature(mUserCert, mPrivateKey, mServerRootCa);
-        final Ikev2VpnProfile profile = builder.build();
-        assertNotNull(profile);
-
-        assertEquals(profile.getUserCert(), mUserCert);
-        assertEquals(mPrivateKey, profile.getRsaPrivateKey());
-        assertEquals(profile.getServerRootCaCert(), mServerRootCa);
-
-        assertNull(profile.getPresharedKey());
-        assertNull(profile.getUsername());
-        assertNull(profile.getPassword());
-    }
-
-    @Test
-    public void testBuildPresharedKeyProfile() throws Exception {
-        final Ikev2VpnProfile.Builder builder = getBuilderWithDefaultOptions();
-
-        builder.setAuthPsk(PSK_BYTES);
-        final Ikev2VpnProfile profile = builder.build();
-        assertNotNull(profile);
-
-        assertArrayEquals(PSK_BYTES, profile.getPresharedKey());
-
-        assertNull(profile.getServerRootCaCert());
-        assertNull(profile.getUsername());
-        assertNull(profile.getPassword());
-        assertNull(profile.getRsaPrivateKey());
-        assertNull(profile.getUserCert());
-    }
-
-    @Test
-    public void testBuildWithAllowedAlgorithmsAead() throws Exception {
-        final Ikev2VpnProfile.Builder builder = getBuilderWithDefaultOptions();
-        builder.setAuthPsk(PSK_BYTES);
-
-        List<String> allowedAlgorithms =
-                Arrays.asList(
-                        IpSecAlgorithm.AUTH_CRYPT_AES_GCM,
-                        IpSecAlgorithm.AUTH_CRYPT_CHACHA20_POLY1305);
-        builder.setAllowedAlgorithms(allowedAlgorithms);
-
-        final Ikev2VpnProfile profile = builder.build();
-        assertEquals(allowedAlgorithms, profile.getAllowedAlgorithms());
-    }
-
-    @Test
-    public void testBuildWithAllowedAlgorithmsNormal() throws Exception {
-        final Ikev2VpnProfile.Builder builder = getBuilderWithDefaultOptions();
-        builder.setAuthPsk(PSK_BYTES);
-
-        List<String> allowedAlgorithms =
-                Arrays.asList(
-                        IpSecAlgorithm.AUTH_HMAC_SHA512,
-                        IpSecAlgorithm.AUTH_AES_XCBC,
-                        IpSecAlgorithm.AUTH_AES_CMAC,
-                        IpSecAlgorithm.CRYPT_AES_CBC,
-                        IpSecAlgorithm.CRYPT_AES_CTR);
-        builder.setAllowedAlgorithms(allowedAlgorithms);
-
-        final Ikev2VpnProfile profile = builder.build();
-        assertEquals(allowedAlgorithms, profile.getAllowedAlgorithms());
-    }
-
-    @Test
-    public void testSetAllowedAlgorithmsEmptyList() throws Exception {
-        final Ikev2VpnProfile.Builder builder = getBuilderWithDefaultOptions();
-
-        try {
-            builder.setAllowedAlgorithms(new ArrayList<>());
-            fail("Expected exception due to no valid algorithm set");
-        } catch (IllegalArgumentException expected) {
-        }
-    }
-
-    @Test
-    public void testSetAllowedAlgorithmsInvalidList() throws Exception {
-        final Ikev2VpnProfile.Builder builder = getBuilderWithDefaultOptions();
-        List<String> allowedAlgorithms = new ArrayList<>();
-
-        try {
-            builder.setAllowedAlgorithms(Arrays.asList(IpSecAlgorithm.AUTH_HMAC_SHA256));
-            fail("Expected exception due to missing encryption");
-        } catch (IllegalArgumentException expected) {
-        }
-
-        try {
-            builder.setAllowedAlgorithms(Arrays.asList(IpSecAlgorithm.CRYPT_AES_CBC));
-            fail("Expected exception due to missing authentication");
-        } catch (IllegalArgumentException expected) {
-        }
-    }
-
-    @Test
-    public void testSetAllowedAlgorithmsInsecureAlgorithm() throws Exception {
-        final Ikev2VpnProfile.Builder builder = getBuilderWithDefaultOptions();
-        List<String> allowedAlgorithms = new ArrayList<>();
-
-        try {
-            builder.setAllowedAlgorithms(Arrays.asList(IpSecAlgorithm.AUTH_HMAC_MD5));
-            fail("Expected exception due to insecure algorithm");
-        } catch (IllegalArgumentException expected) {
-        }
-
-        try {
-            builder.setAllowedAlgorithms(Arrays.asList(IpSecAlgorithm.AUTH_HMAC_SHA1));
-            fail("Expected exception due to insecure algorithm");
-        } catch (IllegalArgumentException expected) {
-        }
-    }
-
-    @Test
-    public void testBuildNoAuthMethodSet() throws Exception {
-        final Ikev2VpnProfile.Builder builder = getBuilderWithDefaultOptions();
-
-        try {
-            builder.build();
-            fail("Expected exception due to lack of auth method");
-        } catch (IllegalArgumentException expected) {
-        }
-    }
-
-
-    // TODO: Refer to Build.VERSION_CODES.SC_V2 when it's available in AOSP and mainline branch
-    @DevSdkIgnoreRule.IgnoreUpTo(SC_V2)
-    @Test
-    public void testBuildExcludeLocalRoutesSet() throws Exception {
-        final Ikev2VpnProfile.Builder builder = getBuilderWithDefaultOptions();
-        builder.setAuthPsk(PSK_BYTES);
-        builder.setLocalRoutesExcluded(true);
-
-        final Ikev2VpnProfile profile = builder.build();
-        assertNotNull(profile);
-        assertTrue(profile.areLocalRoutesExcluded());
-
-        builder.setBypassable(false);
-        try {
-            builder.build();
-            fail("Expected exception because excludeLocalRoutes should be set only"
-                    + " on the bypassable VPN");
-        } catch (IllegalArgumentException expected) {
-        }
-    }
-
-    @Test
-    public void testBuildInvalidMtu() throws Exception {
-        final Ikev2VpnProfile.Builder builder = getBuilderWithDefaultOptions();
-
-        try {
-            builder.setMaxMtu(500);
-            fail("Expected exception due to too-small MTU");
-        } catch (IllegalArgumentException expected) {
-        }
-    }
-
-    private void verifyVpnProfileCommon(VpnProfile profile) {
-        assertEquals(SERVER_ADDR_STRING, profile.server);
-        assertEquals(IDENTITY_STRING, profile.ipsecIdentifier);
-        assertEquals(mProxy, profile.proxy);
-        assertTrue(profile.isBypassable);
-        assertTrue(profile.isMetered);
-        assertEquals(TEST_MTU, profile.maxMtu);
-    }
-
-    @Test
-    public void testPskConvertToVpnProfile() throws Exception {
-        final Ikev2VpnProfile.Builder builder = getBuilderWithDefaultOptions();
-
-        builder.setAuthPsk(PSK_BYTES);
-        final VpnProfile profile = builder.build().toVpnProfile();
-
-        verifyVpnProfileCommon(profile);
-        assertEquals(Ikev2VpnProfile.encodeForIpsecSecret(PSK_BYTES), profile.ipsecSecret);
-
-        // Check nothing else is set
-        assertEquals("", profile.username);
-        assertEquals("", profile.password);
-        assertEquals("", profile.ipsecUserCert);
-        assertEquals("", profile.ipsecCaCert);
-    }
-
-    @Test
-    public void testUsernamePasswordConvertToVpnProfile() throws Exception {
-        final Ikev2VpnProfile.Builder builder = getBuilderWithDefaultOptions();
-
-        builder.setAuthUsernamePassword(USERNAME_STRING, PASSWORD_STRING, mServerRootCa);
-        final VpnProfile profile = builder.build().toVpnProfile();
-
-        verifyVpnProfileCommon(profile);
-        assertEquals(USERNAME_STRING, profile.username);
-        assertEquals(PASSWORD_STRING, profile.password);
-        assertEquals(Ikev2VpnProfile.certificateToPemString(mServerRootCa), profile.ipsecCaCert);
-
-        // Check nothing else is set
-        assertEquals("", profile.ipsecUserCert);
-        assertEquals("", profile.ipsecSecret);
-    }
-
-    @Test
-    public void testRsaConvertToVpnProfile() throws Exception {
-        final Ikev2VpnProfile.Builder builder = getBuilderWithDefaultOptions();
-
-        builder.setAuthDigitalSignature(mUserCert, mPrivateKey, mServerRootCa);
-        final VpnProfile profile = builder.build().toVpnProfile();
-
-        final String expectedSecret = Ikev2VpnProfile.PREFIX_INLINE
-                + Ikev2VpnProfile.encodeForIpsecSecret(mPrivateKey.getEncoded());
-        verifyVpnProfileCommon(profile);
-        assertEquals(Ikev2VpnProfile.certificateToPemString(mUserCert), profile.ipsecUserCert);
-        assertEquals(
-                expectedSecret,
-                profile.ipsecSecret);
-        assertEquals(Ikev2VpnProfile.certificateToPemString(mServerRootCa), profile.ipsecCaCert);
-
-        // Check nothing else is set
-        assertEquals("", profile.username);
-        assertEquals("", profile.password);
-    }
-
-    @Test
-    public void testPskFromVpnProfileDiscardsIrrelevantValues() throws Exception {
-        final Ikev2VpnProfile.Builder builder = getBuilderWithDefaultOptions();
-
-        builder.setAuthPsk(PSK_BYTES);
-        final VpnProfile profile = builder.build().toVpnProfile();
-        profile.username = USERNAME_STRING;
-        profile.password = PASSWORD_STRING;
-        profile.ipsecCaCert = Ikev2VpnProfile.certificateToPemString(mServerRootCa);
-        profile.ipsecUserCert = Ikev2VpnProfile.certificateToPemString(mUserCert);
-
-        final Ikev2VpnProfile result = Ikev2VpnProfile.fromVpnProfile(profile);
-        assertNull(result.getUsername());
-        assertNull(result.getPassword());
-        assertNull(result.getUserCert());
-        assertNull(result.getRsaPrivateKey());
-        assertNull(result.getServerRootCaCert());
-    }
-
-    @Test
-    public void testUsernamePasswordFromVpnProfileDiscardsIrrelevantValues() throws Exception {
-        final Ikev2VpnProfile.Builder builder = getBuilderWithDefaultOptions();
-
-        builder.setAuthUsernamePassword(USERNAME_STRING, PASSWORD_STRING, mServerRootCa);
-        final VpnProfile profile = builder.build().toVpnProfile();
-        profile.ipsecSecret = new String(PSK_BYTES);
-        profile.ipsecUserCert = Ikev2VpnProfile.certificateToPemString(mUserCert);
-
-        final Ikev2VpnProfile result = Ikev2VpnProfile.fromVpnProfile(profile);
-        assertNull(result.getPresharedKey());
-        assertNull(result.getUserCert());
-        assertNull(result.getRsaPrivateKey());
-    }
-
-    @Test
-    public void testRsaFromVpnProfileDiscardsIrrelevantValues() throws Exception {
-        final Ikev2VpnProfile.Builder builder = getBuilderWithDefaultOptions();
-
-        builder.setAuthDigitalSignature(mUserCert, mPrivateKey, mServerRootCa);
-        final VpnProfile profile = builder.build().toVpnProfile();
-        profile.username = USERNAME_STRING;
-        profile.password = PASSWORD_STRING;
-
-        final Ikev2VpnProfile result = Ikev2VpnProfile.fromVpnProfile(profile);
-        assertNull(result.getUsername());
-        assertNull(result.getPassword());
-        assertNull(result.getPresharedKey());
-    }
-
-    @Test
-    public void testPskConversionIsLossless() throws Exception {
-        final Ikev2VpnProfile.Builder builder = getBuilderWithDefaultOptions();
-
-        builder.setAuthPsk(PSK_BYTES);
-        final Ikev2VpnProfile ikeProfile = builder.build();
-
-        assertEquals(ikeProfile, Ikev2VpnProfile.fromVpnProfile(ikeProfile.toVpnProfile()));
-    }
-
-    @Test
-    public void testUsernamePasswordConversionIsLossless() throws Exception {
-        final Ikev2VpnProfile.Builder builder = getBuilderWithDefaultOptions();
-
-        builder.setAuthUsernamePassword(USERNAME_STRING, PASSWORD_STRING, mServerRootCa);
-        final Ikev2VpnProfile ikeProfile = builder.build();
-
-        assertEquals(ikeProfile, Ikev2VpnProfile.fromVpnProfile(ikeProfile.toVpnProfile()));
-    }
-
-    @Test
-    public void testRsaConversionIsLossless() throws Exception {
-        final Ikev2VpnProfile.Builder builder = getBuilderWithDefaultOptions();
-
-        builder.setAuthDigitalSignature(mUserCert, mPrivateKey, mServerRootCa);
-        final Ikev2VpnProfile ikeProfile = builder.build();
-
-        assertEquals(ikeProfile, Ikev2VpnProfile.fromVpnProfile(ikeProfile.toVpnProfile()));
-    }
-
-    @Test
-    public void testBuildWithIkeTunConnParamsConvertToVpnProfile() throws Exception {
-        // Special keyId that contains delimiter character of VpnProfile
-        final byte[] keyId = "foo\0bar".getBytes();
-        final IkeTunnelConnectionParams tunnelParams = new IkeTunnelConnectionParams(
-                getTestIkeSessionParams(true /* testIpv6 */, new IkeKeyIdIdentification(keyId)),
-                CHILD_PARAMS);
-        final Ikev2VpnProfile ikev2VpnProfile = new Ikev2VpnProfile.Builder(tunnelParams).build();
-        final VpnProfile vpnProfile = ikev2VpnProfile.toVpnProfile();
-
-        assertEquals(VpnProfile.TYPE_IKEV2_FROM_IKE_TUN_CONN_PARAMS, vpnProfile.type);
-
-        // Username, password, server, ipsecIdentifier, ipsecCaCert, ipsecSecret, ipsecUserCert and
-        // getAllowedAlgorithms should not be set if IkeTunnelConnectionParams is set.
-        assertEquals("", vpnProfile.server);
-        assertEquals("", vpnProfile.ipsecIdentifier);
-        assertEquals("", vpnProfile.username);
-        assertEquals("", vpnProfile.password);
-        assertEquals("", vpnProfile.ipsecCaCert);
-        assertEquals("", vpnProfile.ipsecSecret);
-        assertEquals("", vpnProfile.ipsecUserCert);
-        assertEquals(0, vpnProfile.getAllowedAlgorithms().size());
-
-        // IkeTunnelConnectionParams should stay the same.
-        assertEquals(tunnelParams, vpnProfile.ikeTunConnParams);
-
-        // Convert to disk-stable format and then back to Ikev2VpnProfile should be the same.
-        final VpnProfile decodedVpnProfile =
-                VpnProfile.decode(vpnProfile.key, vpnProfile.encode());
-        final Ikev2VpnProfile convertedIkev2VpnProfile =
-                Ikev2VpnProfile.fromVpnProfile(decodedVpnProfile);
-        assertEquals(ikev2VpnProfile, convertedIkev2VpnProfile);
-    }
-
-    @Test
-    public void testConversionIsLosslessWithIkeTunConnParams() throws Exception {
-        final IkeTunnelConnectionParams tunnelParams =
-                new IkeTunnelConnectionParams(IKE_PARAMS_V6, CHILD_PARAMS);
-        // Config authentication related fields is not required while building with
-        // IkeTunnelConnectionParams.
-        final Ikev2VpnProfile ikeProfile = new Ikev2VpnProfile.Builder(tunnelParams).build();
-        assertEquals(ikeProfile, Ikev2VpnProfile.fromVpnProfile(ikeProfile.toVpnProfile()));
-    }
-
-    @Test
-    public void testAutomaticNattAndIpVersionConversionIsLossless() throws Exception {
-        final Ikev2VpnProfile.Builder builder = getBuilderWithDefaultOptions();
-        builder.setAutomaticNattKeepaliveTimerEnabled(true);
-        builder.setAutomaticIpVersionSelectionEnabled(true);
-
-        builder.setAuthDigitalSignature(mUserCert, mPrivateKey, mServerRootCa);
-        final Ikev2VpnProfile ikeProfile = builder.build();
-
-        assertEquals(ikeProfile, Ikev2VpnProfile.fromVpnProfile(ikeProfile.toVpnProfile()));
-    }
-
-    @Test
-    public void testAutomaticNattAndIpVersionDefaults() throws Exception {
-        final Ikev2VpnProfile.Builder builder = getBuilderWithDefaultOptions();
-
-        builder.setAuthDigitalSignature(mUserCert, mPrivateKey, mServerRootCa);
-        final Ikev2VpnProfile ikeProfile = builder.build();
-
-        assertEquals(false, ikeProfile.isAutomaticNattKeepaliveTimerEnabled());
-        assertEquals(false, ikeProfile.isAutomaticIpVersionSelectionEnabled());
-    }
-
-    @Test
-    public void testEquals() throws Exception {
-        // Verify building without IkeTunnelConnectionParams
-        final Ikev2VpnProfile.Builder builder = getBuilderWithDefaultOptions();
-        builder.setAuthDigitalSignature(mUserCert, mPrivateKey, mServerRootCa);
-        assertEquals(builder.build(), builder.build());
-
-        // Verify building with IkeTunnelConnectionParams
-        final IkeTunnelConnectionParams tunnelParams =
-                new IkeTunnelConnectionParams(IKE_PARAMS_V6, CHILD_PARAMS);
-        final IkeTunnelConnectionParams tunnelParams2 =
-                new IkeTunnelConnectionParams(IKE_PARAMS_V6, CHILD_PARAMS);
-        assertEquals(new Ikev2VpnProfile.Builder(tunnelParams).build(),
-                new Ikev2VpnProfile.Builder(tunnelParams2).build());
-    }
-
-    @Test
-    public void testBuildProfileWithNullProxy() throws Exception {
-        final Ikev2VpnProfile ikev2VpnProfile =
-                new Ikev2VpnProfile.Builder(SERVER_ADDR_STRING, IDENTITY_STRING)
-                        .setAuthUsernamePassword(USERNAME_STRING, PASSWORD_STRING, mServerRootCa)
-                        .build();
-
-        // ProxyInfo should be null for the profile without setting ProxyInfo.
-        assertNull(ikev2VpnProfile.getProxyInfo());
-
-        // ProxyInfo should stay null after performing toVpnProfile() and fromVpnProfile()
-        final VpnProfile vpnProfile = ikev2VpnProfile.toVpnProfile();
-        assertNull(vpnProfile.proxy);
-
-        final Ikev2VpnProfile convertedIkev2VpnProfile = Ikev2VpnProfile.fromVpnProfile(vpnProfile);
-        assertNull(convertedIkev2VpnProfile.getProxyInfo());
-    }
-
-    private static class CertificateAndKey {
-        public final X509Certificate cert;
-        public final PrivateKey key;
-
-        CertificateAndKey(X509Certificate cert, PrivateKey key) {
-            this.cert = cert;
-            this.key = key;
-        }
-    }
-
-    private static CertificateAndKey generateRandomCertAndKeyPair() throws Exception {
-        final Date validityBeginDate =
-                new Date(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(1L));
-        final Date validityEndDate =
-                new Date(System.currentTimeMillis() + TimeUnit.DAYS.toMillis(1L));
-
-        // Generate a keypair
-        final KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
-        keyPairGenerator.initialize(512);
-        final KeyPair keyPair = keyPairGenerator.generateKeyPair();
-
-        final X500Principal dnName = new X500Principal("CN=test.android.com");
-        final X509V1CertificateGenerator certGen = new X509V1CertificateGenerator();
-        certGen.setSerialNumber(BigInteger.valueOf(System.currentTimeMillis()));
-        certGen.setSubjectDN(dnName);
-        certGen.setIssuerDN(dnName);
-        certGen.setNotBefore(validityBeginDate);
-        certGen.setNotAfter(validityEndDate);
-        certGen.setPublicKey(keyPair.getPublic());
-        certGen.setSignatureAlgorithm("SHA256WithRSAEncryption");
-
-        final X509Certificate cert = certGen.generate(keyPair.getPrivate(), "AndroidOpenSSL");
-        return new CertificateAndKey(cert, keyPair.getPrivate());
-    }
-}
diff --git a/tests/unit/java/android/net/BpfNetMapsReaderTest.kt b/tests/unit/java/android/net/NetworkStackBpfNetMapsTest.kt
similarity index 83%
rename from tests/unit/java/android/net/BpfNetMapsReaderTest.kt
rename to tests/unit/java/android/net/NetworkStackBpfNetMapsTest.kt
index 8919666..a9ccbdd 100644
--- a/tests/unit/java/android/net/BpfNetMapsReaderTest.kt
+++ b/tests/unit/java/android/net/NetworkStackBpfNetMapsTest.kt
@@ -26,6 +26,7 @@
 import android.net.BpfNetMapsConstants.UID_RULES_CONFIGURATION_KEY
 import android.net.BpfNetMapsUtils.getMatchByFirewallChain
 import android.os.Build.VERSION_CODES
+import android.os.Process.FIRST_APPLICATION_UID
 import com.android.net.module.util.IBpfMap
 import com.android.net.module.util.Struct.S32
 import com.android.net.module.util.Struct.U32
@@ -42,7 +43,7 @@
 import org.junit.Test
 import org.junit.runner.RunWith
 
-private const val TEST_UID1 = 1234
+private const val TEST_UID1 = 11234
 private const val TEST_UID2 = TEST_UID1 + 1
 private const val TEST_UID3 = TEST_UID2 + 1
 private const val NO_IIF = 0
@@ -50,7 +51,7 @@
 // pre-T devices does not support Bpf.
 @RunWith(DevSdkIgnoreRunner::class)
 @IgnoreUpTo(VERSION_CODES.S_V2)
-class BpfNetMapsReaderTest {
+class NetworkStackBpfNetMapsTest {
     @Rule
     @JvmField
     val ignoreRule = DevSdkIgnoreRule()
@@ -58,14 +59,15 @@
     private val testConfigurationMap: IBpfMap<S32, U32> = TestBpfMap()
     private val testUidOwnerMap: IBpfMap<S32, UidOwnerValue> = TestBpfMap()
     private val testDataSaverEnabledMap: IBpfMap<S32, U8> = TestBpfMap()
-    private val bpfNetMapsReader = BpfNetMapsReader(
-        TestDependencies(testConfigurationMap, testUidOwnerMap, testDataSaverEnabledMap))
+    private val bpfNetMapsReader = NetworkStackBpfNetMaps(
+        TestDependencies(testConfigurationMap, testUidOwnerMap, testDataSaverEnabledMap)
+    )
 
     class TestDependencies(
         private val configMap: IBpfMap<S32, U32>,
         private val uidOwnerMap: IBpfMap<S32, UidOwnerValue>,
         private val dataSaverEnabledMap: IBpfMap<S32, U8>
-    ) : BpfNetMapsReader.Dependencies() {
+    ) : NetworkStackBpfNetMaps.Dependencies() {
         override fun getConfigurationMap() = configMap
         override fun getUidOwnerMap() = uidOwnerMap
         override fun getDataSaverEnabledMap() = dataSaverEnabledMap
@@ -99,11 +101,16 @@
             Modifier.isStatic(it.modifiers) && it.name.startsWith("FIREWALL_CHAIN_")
         }
         // Verify the size matches, this also verifies no common item in allow and deny chains.
-        assertEquals(BpfNetMapsConstants.ALLOW_CHAINS.size +
-                BpfNetMapsConstants.DENY_CHAINS.size, declaredChains.size)
+        assertEquals(
+            BpfNetMapsConstants.ALLOW_CHAINS.size +
+                BpfNetMapsConstants.DENY_CHAINS.size,
+            declaredChains.size
+        )
         declaredChains.forEach {
-            assertTrue(BpfNetMapsConstants.ALLOW_CHAINS.contains(it.get(null)) ||
-                    BpfNetMapsConstants.DENY_CHAINS.contains(it.get(null)))
+            assertTrue(
+                BpfNetMapsConstants.ALLOW_CHAINS.contains(it.get(null)) ||
+                    BpfNetMapsConstants.DENY_CHAINS.contains(it.get(null))
+            )
         }
     }
 
@@ -117,11 +124,17 @@
         testConfigurationMap.updateEntry(UID_RULES_CONFIGURATION_KEY, U32(newConfig))
     }
 
-    fun isUidNetworkingBlocked(uid: Int, metered: Boolean = false, dataSaver: Boolean = false) =
-            bpfNetMapsReader.isUidNetworkingBlocked(uid, metered, dataSaver)
+    private fun mockDataSaverEnabled(enabled: Boolean) {
+        val dataSaverValue = if (enabled) {DATA_SAVER_ENABLED} else {DATA_SAVER_DISABLED}
+        testDataSaverEnabledMap.updateEntry(DATA_SAVER_ENABLED_KEY, U8(dataSaverValue))
+    }
+
+    fun isUidNetworkingBlocked(uid: Int, metered: Boolean = false) =
+            bpfNetMapsReader.isUidNetworkingBlocked(uid, metered)
 
     @Test
     fun testIsUidNetworkingBlockedByFirewallChains_allowChain() {
+        mockDataSaverEnabled(enabled = false)
         // With everything disabled by default, verify the return value is false.
         testConfigurationMap.updateEntry(UID_RULES_CONFIGURATION_KEY, U32(0))
         assertFalse(isUidNetworkingBlocked(TEST_UID1))
@@ -141,6 +154,7 @@
 
     @Test
     fun testIsUidNetworkingBlockedByFirewallChains_denyChain() {
+        mockDataSaverEnabled(enabled = false)
         // Enable standby chain but does not provide denied list. Verify the network is allowed
         // for all uids.
         testConfigurationMap.updateEntry(UID_RULES_CONFIGURATION_KEY, U32(0))
@@ -162,12 +176,14 @@
         testConfigurationMap.updateEntry(UID_RULES_CONFIGURATION_KEY, U32(0))
         mockChainEnabled(ConnectivityManager.FIREWALL_CHAIN_POWERSAVE, true)
         mockChainEnabled(ConnectivityManager.FIREWALL_CHAIN_STANDBY, true)
+        mockDataSaverEnabled(enabled = false)
         assertTrue(isUidNetworkingBlocked(TEST_UID1))
     }
 
     @IgnoreUpTo(VERSION_CODES.S_V2)
     @Test
     fun testIsUidNetworkingBlockedByDataSaver() {
+        mockDataSaverEnabled(enabled = false)
         // With everything disabled by default, verify the return value is false.
         testConfigurationMap.updateEntry(UID_RULES_CONFIGURATION_KEY, U32(0))
         assertFalse(isUidNetworkingBlocked(TEST_UID1, metered = true))
@@ -180,10 +196,11 @@
 
         // Enable data saver, verify the network is blocked for uid1, uid2, but uid3 in happy box
         // is not affected.
+        mockDataSaverEnabled(enabled = true)
         testUidOwnerMap.updateEntry(S32(TEST_UID3), UidOwnerValue(NO_IIF, HAPPY_BOX_MATCH))
-        assertTrue(isUidNetworkingBlocked(TEST_UID1, metered = true, dataSaver = true))
-        assertTrue(isUidNetworkingBlocked(TEST_UID2, metered = true, dataSaver = true))
-        assertFalse(isUidNetworkingBlocked(TEST_UID3, metered = true, dataSaver = true))
+        assertTrue(isUidNetworkingBlocked(TEST_UID1, metered = true))
+        assertTrue(isUidNetworkingBlocked(TEST_UID2, metered = true))
+        assertFalse(isUidNetworkingBlocked(TEST_UID3, metered = true))
 
         // Add uid1 to happy box as well, verify nothing is changed because penalty box has higher
         // priority.
@@ -191,18 +208,19 @@
             S32(TEST_UID1),
             UidOwnerValue(NO_IIF, PENALTY_BOX_MATCH or HAPPY_BOX_MATCH)
         )
-        assertTrue(isUidNetworkingBlocked(TEST_UID1, metered = true, dataSaver = true))
-        assertTrue(isUidNetworkingBlocked(TEST_UID2, metered = true, dataSaver = true))
-        assertFalse(isUidNetworkingBlocked(TEST_UID3, metered = true, dataSaver = true))
+        assertTrue(isUidNetworkingBlocked(TEST_UID1, metered = true))
+        assertTrue(isUidNetworkingBlocked(TEST_UID2, metered = true))
+        assertFalse(isUidNetworkingBlocked(TEST_UID3, metered = true))
 
         // Enable doze mode, verify uid3 is blocked even if it is in happy box.
         mockChainEnabled(ConnectivityManager.FIREWALL_CHAIN_DOZABLE, true)
-        assertTrue(isUidNetworkingBlocked(TEST_UID1, metered = true, dataSaver = true))
-        assertTrue(isUidNetworkingBlocked(TEST_UID2, metered = true, dataSaver = true))
-        assertTrue(isUidNetworkingBlocked(TEST_UID3, metered = true, dataSaver = true))
+        assertTrue(isUidNetworkingBlocked(TEST_UID1, metered = true))
+        assertTrue(isUidNetworkingBlocked(TEST_UID2, metered = true))
+        assertTrue(isUidNetworkingBlocked(TEST_UID3, metered = true))
 
         // Disable doze mode and data saver, only uid1 which is in penalty box is blocked.
         mockChainEnabled(ConnectivityManager.FIREWALL_CHAIN_DOZABLE, false)
+        mockDataSaverEnabled(enabled = false)
         assertTrue(isUidNetworkingBlocked(TEST_UID1, metered = true))
         assertFalse(isUidNetworkingBlocked(TEST_UID2, metered = true))
         assertFalse(isUidNetworkingBlocked(TEST_UID3, metered = true))
@@ -214,6 +232,24 @@
     }
 
     @Test
+    fun testIsUidNetworkingBlocked_SystemUid() {
+        mockDataSaverEnabled(enabled = false)
+        testConfigurationMap.updateEntry(UID_RULES_CONFIGURATION_KEY, U32(0))
+        mockChainEnabled(ConnectivityManager.FIREWALL_CHAIN_DOZABLE, true)
+
+        for (uid in FIRST_APPLICATION_UID - 5..FIRST_APPLICATION_UID + 5) {
+            // system uid is not blocked regardless of firewall chains
+            val expectBlocked = uid >= FIRST_APPLICATION_UID
+            testUidOwnerMap.updateEntry(S32(uid), UidOwnerValue(NO_IIF, PENALTY_BOX_MATCH))
+            assertEquals(
+                expectBlocked,
+                    isUidNetworkingBlocked(uid, metered = true),
+                    "isUidNetworkingBlocked returns unexpected value for uid = " + uid
+            )
+        }
+    }
+
+    @Test
     fun testGetDataSaverEnabled() {
         testDataSaverEnabledMap.updateEntry(DATA_SAVER_ENABLED_KEY, U8(DATA_SAVER_DISABLED))
         assertFalse(bpfNetMapsReader.dataSaverEnabled)
diff --git a/tests/unit/java/android/net/VpnManagerTest.java b/tests/unit/java/android/net/VpnManagerTest.java
deleted file mode 100644
index 2ab4e45..0000000
--- a/tests/unit/java/android/net/VpnManagerTest.java
+++ /dev/null
@@ -1,148 +0,0 @@
-/*
- * Copyright (C) 2019 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 android.net;
-
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertNotNull;
-import static org.junit.Assert.assertNull;
-import static org.junit.Assume.assumeFalse;
-import static org.mockito.Matchers.any;
-import static org.mockito.Matchers.eq;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
-
-import android.content.ComponentName;
-import android.content.Intent;
-import android.content.pm.PackageManager;
-import android.os.Build;
-import android.test.mock.MockContext;
-import android.util.SparseArray;
-
-import androidx.test.filters.SmallTest;
-import androidx.test.InstrumentationRegistry;
-
-import com.android.internal.net.VpnProfile;
-import com.android.internal.util.MessageUtils;
-import com.android.testutils.DevSdkIgnoreRule;
-import com.android.testutils.DevSdkIgnoreRunner;
-
-import org.junit.Before;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-
-/** Unit tests for {@link VpnManager}. */
-@SmallTest
-@RunWith(DevSdkIgnoreRunner.class)
-@DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.R)
-public class VpnManagerTest {
-
-    private static final String PKG_NAME = "fooPackage";
-
-    private static final String SESSION_NAME_STRING = "testSession";
-    private static final String SERVER_ADDR_STRING = "1.2.3.4";
-    private static final String IDENTITY_STRING = "Identity";
-    private static final byte[] PSK_BYTES = "preSharedKey".getBytes();
-
-    private IVpnManager mMockService;
-    private VpnManager mVpnManager;
-    private final MockContext mMockContext =
-            new MockContext() {
-                @Override
-                public String getOpPackageName() {
-                    return PKG_NAME;
-                }
-            };
-
-    @Before
-    public void setUp() throws Exception {
-        assumeFalse("Skipping test because watches don't support VPN",
-            InstrumentationRegistry.getContext().getPackageManager().hasSystemFeature(
-                PackageManager.FEATURE_WATCH));
-        mMockService = mock(IVpnManager.class);
-        mVpnManager = new VpnManager(mMockContext, mMockService);
-    }
-
-    @Test
-    public void testProvisionVpnProfilePreconsented() throws Exception {
-        final PlatformVpnProfile profile = getPlatformVpnProfile();
-        when(mMockService.provisionVpnProfile(any(VpnProfile.class), eq(PKG_NAME)))
-                .thenReturn(true);
-
-        // Expect there to be no intent returned, as consent has already been granted.
-        assertNull(mVpnManager.provisionVpnProfile(profile));
-        verify(mMockService).provisionVpnProfile(eq(profile.toVpnProfile()), eq(PKG_NAME));
-    }
-
-    @Test
-    public void testProvisionVpnProfileNeedsConsent() throws Exception {
-        final PlatformVpnProfile profile = getPlatformVpnProfile();
-        when(mMockService.provisionVpnProfile(any(VpnProfile.class), eq(PKG_NAME)))
-                .thenReturn(false);
-
-        // Expect intent to be returned, as consent has not already been granted.
-        final Intent intent = mVpnManager.provisionVpnProfile(profile);
-        assertNotNull(intent);
-
-        final ComponentName expectedComponentName =
-                ComponentName.unflattenFromString(
-                        "com.android.vpndialogs/com.android.vpndialogs.PlatformVpnConfirmDialog");
-        assertEquals(expectedComponentName, intent.getComponent());
-        verify(mMockService).provisionVpnProfile(eq(profile.toVpnProfile()), eq(PKG_NAME));
-    }
-
-    @Test
-    public void testDeleteProvisionedVpnProfile() throws Exception {
-        mVpnManager.deleteProvisionedVpnProfile();
-        verify(mMockService).deleteVpnProfile(eq(PKG_NAME));
-    }
-
-    @Test
-    public void testStartProvisionedVpnProfile() throws Exception {
-        mVpnManager.startProvisionedVpnProfile();
-        verify(mMockService).startVpnProfile(eq(PKG_NAME));
-    }
-
-    @Test
-    public void testStopProvisionedVpnProfile() throws Exception {
-        mVpnManager.stopProvisionedVpnProfile();
-        verify(mMockService).stopVpnProfile(eq(PKG_NAME));
-    }
-
-    private Ikev2VpnProfile getPlatformVpnProfile() throws Exception {
-        return new Ikev2VpnProfile.Builder(SERVER_ADDR_STRING, IDENTITY_STRING)
-                .setBypassable(true)
-                .setMaxMtu(1300)
-                .setMetered(true)
-                .setAuthPsk(PSK_BYTES)
-                .build();
-    }
-
-    @Test
-    public void testVpnTypesEqual() throws Exception {
-        SparseArray<String> vmVpnTypes = MessageUtils.findMessageNames(
-                new Class[] { VpnManager.class }, new String[]{ "TYPE_VPN_" });
-        SparseArray<String> nativeVpnType = MessageUtils.findMessageNames(
-                new Class[] { NativeVpnType.class }, new String[]{ "" });
-
-        // TYPE_VPN_NONE = -1 is only defined in VpnManager.
-        assertEquals(vmVpnTypes.size() - 1, nativeVpnType.size());
-        for (int i = VpnManager.TYPE_VPN_SERVICE; i < vmVpnTypes.size(); i++) {
-            assertEquals(vmVpnTypes.get(i), "TYPE_VPN_" + nativeVpnType.get(i));
-        }
-    }
-}
diff --git a/tests/unit/java/android/net/nsd/AdvertisingRequestTest.kt b/tests/unit/java/android/net/nsd/AdvertisingRequestTest.kt
new file mode 100644
index 0000000..c491f37
--- /dev/null
+++ b/tests/unit/java/android/net/nsd/AdvertisingRequestTest.kt
@@ -0,0 +1,106 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net.nsd
+
+import android.net.nsd.AdvertisingRequest.NSD_ADVERTISING_UPDATE_ONLY
+import android.net.nsd.NsdManager.PROTOCOL_DNS_SD
+import android.os.Build
+import androidx.test.filters.SmallTest
+import com.android.testutils.ConnectivityModuleTest
+import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo
+import com.android.testutils.DevSdkIgnoreRunner
+import com.android.testutils.parcelingRoundTrip
+import java.time.Duration
+import kotlin.test.assertEquals
+import kotlin.test.assertNotEquals
+import kotlin.test.assertNull
+import org.junit.Test
+import org.junit.runner.RunWith
+
+// TODO: move this class to CTS tests when AdvertisingRequest is made public
+/** Unit tests for {@link AdvertisingRequest}. */
+@IgnoreUpTo(Build.VERSION_CODES.S_V2)
+@RunWith(DevSdkIgnoreRunner::class)
+@SmallTest
+@ConnectivityModuleTest
+class AdvertisingRequestTest {
+    @Test
+    fun testParcelingIsLossLess() {
+        val info = NsdServiceInfo().apply {
+            serviceType = "_ipp._tcp"
+        }
+        val beforeParcel = AdvertisingRequest.Builder(info, PROTOCOL_DNS_SD)
+                .setAdvertisingConfig(NSD_ADVERTISING_UPDATE_ONLY)
+                .setTtl(Duration.ofSeconds(30L))
+                .build()
+
+        val afterParcel = parcelingRoundTrip(beforeParcel)
+
+        assertEquals(beforeParcel.serviceInfo.serviceType, afterParcel.serviceInfo.serviceType)
+        assertEquals(beforeParcel.advertisingConfig, afterParcel.advertisingConfig)
+    }
+
+    @Test
+    fun testBuilder_setNullTtl_success() {
+        val info = NsdServiceInfo().apply {
+            serviceType = "_ipp._tcp"
+        }
+        val request = AdvertisingRequest.Builder(info, PROTOCOL_DNS_SD)
+                .setTtl(null)
+                .build()
+
+        assertNull(request.ttl)
+    }
+
+    @Test
+    fun testBuilder_setPropertiesSuccess() {
+        val info = NsdServiceInfo().apply {
+            serviceType = "_ipp._tcp"
+        }
+        val request = AdvertisingRequest.Builder(info, PROTOCOL_DNS_SD)
+                .setAdvertisingConfig(NSD_ADVERTISING_UPDATE_ONLY)
+                .setTtl(Duration.ofSeconds(100L))
+                .build()
+
+        assertEquals("_ipp._tcp", request.serviceInfo.serviceType)
+        assertEquals(PROTOCOL_DNS_SD, request.protocolType)
+        assertEquals(NSD_ADVERTISING_UPDATE_ONLY, request.advertisingConfig)
+        assertEquals(Duration.ofSeconds(100L), request.ttl)
+    }
+
+    @Test
+    fun testEquality() {
+        val info = NsdServiceInfo().apply {
+            serviceType = "_ipp._tcp"
+        }
+        val request1 = AdvertisingRequest.Builder(info, PROTOCOL_DNS_SD).build()
+        val request2 = AdvertisingRequest.Builder(info, PROTOCOL_DNS_SD).build()
+        val request3 = AdvertisingRequest.Builder(info, PROTOCOL_DNS_SD)
+                .setAdvertisingConfig(NSD_ADVERTISING_UPDATE_ONLY)
+                .setTtl(Duration.ofSeconds(120L))
+                .build()
+        val request4 = AdvertisingRequest.Builder(info, PROTOCOL_DNS_SD)
+                .setAdvertisingConfig(NSD_ADVERTISING_UPDATE_ONLY)
+                .setTtl(Duration.ofSeconds(120L))
+                .build()
+
+        assertEquals(request1, request2)
+        assertEquals(request3, request4)
+        assertNotEquals(request1, request3)
+        assertNotEquals(request2, request4)
+    }
+}
diff --git a/tests/unit/java/android/net/nsd/NsdManagerTest.java b/tests/unit/java/android/net/nsd/NsdManagerTest.java
index 951675c..27c4561 100644
--- a/tests/unit/java/android/net/nsd/NsdManagerTest.java
+++ b/tests/unit/java/android/net/nsd/NsdManagerTest.java
@@ -23,6 +23,7 @@
 import static org.junit.Assert.fail;
 import static org.mockito.ArgumentMatchers.anyBoolean;
 import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.anyInt;
 import static org.mockito.Mockito.doReturn;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
@@ -54,6 +55,7 @@
 
 import java.net.InetAddress;
 import java.util.List;
+import java.time.Duration;
 
 @DevSdkIgnoreRunner.MonitorThreadLeak
 @RunWith(DevSdkIgnoreRunner.class)
@@ -224,6 +226,23 @@
         verify(listener, timeout(mTimeoutMs).times(1)).onServiceRegistered(request);
     }
 
+    @Test
+    public void testRegisterServiceWithCustomTtl() throws Exception {
+        final NsdManager manager = mManager;
+        final NsdServiceInfo info = new NsdServiceInfo("another_name2", "another_type2");
+        info.setPort(2203);
+        final AdvertisingRequest request = new AdvertisingRequest.Builder(info, PROTOCOL)
+                .setTtl(Duration.ofSeconds(30)).build();
+        final NsdManager.RegistrationListener listener = mock(
+                NsdManager.RegistrationListener.class);
+
+        manager.registerService(request, Runnable::run, listener);
+
+        AdvertisingRequest capturedRequest = getAdvertisingRequest(
+                req -> verify(mServiceConn).registerService(anyInt(), req.capture()));
+        assertEquals(request.getTtl(), capturedRequest.getTtl());
+    }
+
     private void doTestRegisterService() throws Exception {
         NsdManager manager = mManager;
 
@@ -501,4 +520,12 @@
         verifier.accept(captor);
         return captor.getValue();
     }
+
+    AdvertisingRequest getAdvertisingRequest(
+            ThrowingConsumer<ArgumentCaptor<AdvertisingRequest>> verifier) throws Exception {
+        final ArgumentCaptor<AdvertisingRequest> captor =
+                ArgumentCaptor.forClass(AdvertisingRequest.class);
+        verifier.accept(captor);
+        return captor.getValue();
+    }
 }
diff --git a/tests/unit/java/android/net/nsd/NsdServiceInfoTest.kt b/tests/unit/java/android/net/nsd/NsdServiceInfoTest.kt
new file mode 100644
index 0000000..8f86f06
--- /dev/null
+++ b/tests/unit/java/android/net/nsd/NsdServiceInfoTest.kt
@@ -0,0 +1,51 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net.nsd
+
+import android.os.Build
+import androidx.test.filters.SmallTest
+import com.android.testutils.ConnectivityModuleTest
+import com.android.testutils.DevSdkIgnoreRule
+import com.android.testutils.DevSdkIgnoreRunner
+import kotlin.test.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/** Unit tests for {@link NsdServiceInfo}. */
+@SmallTest
+@ConnectivityModuleTest
+@RunWith(DevSdkIgnoreRunner::class)
+@DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.S_V2)
+class NsdServiceInfoTest {
+    @Test
+    fun testToString_txtRecord() {
+        val info = NsdServiceInfo().apply {
+            this.setAttribute("abc", byteArrayOf(0xff.toByte(), 0xfe.toByte()))
+            this.setAttribute("def", null as String?)
+            this.setAttribute("ghi", "猫")
+            this.setAttribute("jkl", byteArrayOf(0, 0x21))
+            this.setAttribute("mno", "Hey Tom! It's you?.~{}")
+        }
+
+        val infoStr = info.toString()
+
+        assertTrue(
+            infoStr.contains("txtRecord: " +
+                "{abc=0xFFFE, def=(null), ghi=0xE78CAB, jkl=0x0021, mno=Hey Tom! It's you?.~{}}"),
+            infoStr)
+    }
+}
diff --git a/tests/unit/java/com/android/internal/net/VpnProfileTest.java b/tests/unit/java/com/android/internal/net/VpnProfileTest.java
deleted file mode 100644
index acae7d2..0000000
--- a/tests/unit/java/com/android/internal/net/VpnProfileTest.java
+++ /dev/null
@@ -1,323 +0,0 @@
-/*
- * Copyright (C) 2019 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.internal.net;
-
-import static android.net.cts.util.IkeSessionTestUtils.CHILD_PARAMS;
-import static android.net.cts.util.IkeSessionTestUtils.IKE_PARAMS_V4;
-
-import static com.android.modules.utils.build.SdkLevel.isAtLeastT;
-import static com.android.modules.utils.build.SdkLevel.isAtLeastU;
-import static com.android.testutils.ParcelUtils.assertParcelSane;
-
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertFalse;
-import static org.junit.Assert.assertNotEquals;
-import static org.junit.Assert.assertNotSame;
-import static org.junit.Assert.assertNull;
-import static org.junit.Assert.assertTrue;
-
-import android.net.IpSecAlgorithm;
-import android.net.ipsec.ike.IkeTunnelConnectionParams;
-import android.os.Build;
-
-import androidx.test.filters.SmallTest;
-
-import com.android.testutils.DevSdkIgnoreRule;
-import com.android.testutils.DevSdkIgnoreRunner;
-
-import org.junit.Test;
-import org.junit.runner.RunWith;
-
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.List;
-
-/** Unit tests for {@link VpnProfile}. */
-@SmallTest
-@RunWith(DevSdkIgnoreRunner.class)
-@DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.R)
-public class VpnProfileTest {
-    private static final String DUMMY_PROFILE_KEY = "Test";
-
-    private static final int ENCODED_INDEX_AUTH_PARAMS_INLINE = 23;
-    private static final int ENCODED_INDEX_RESTRICTED_TO_TEST_NETWORKS = 24;
-    private static final int ENCODED_INDEX_EXCLUDE_LOCAL_ROUTE = 25;
-    private static final int ENCODED_INDEX_REQUIRE_PLATFORM_VALIDATION = 26;
-    private static final int ENCODED_INDEX_IKE_TUN_CONN_PARAMS = 27;
-    private static final int ENCODED_INDEX_AUTOMATIC_NATT_KEEPALIVE_TIMER_ENABLED = 28;
-    private static final int ENCODED_INDEX_AUTOMATIC_IP_VERSION_SELECTION_ENABLED = 29;
-
-    @Test
-    public void testDefaults() throws Exception {
-        final VpnProfile p = new VpnProfile(DUMMY_PROFILE_KEY);
-
-        assertEquals(DUMMY_PROFILE_KEY, p.key);
-        assertEquals("", p.name);
-        assertEquals(VpnProfile.TYPE_PPTP, p.type);
-        assertEquals("", p.server);
-        assertEquals("", p.username);
-        assertEquals("", p.password);
-        assertEquals("", p.dnsServers);
-        assertEquals("", p.searchDomains);
-        assertEquals("", p.routes);
-        assertTrue(p.mppe);
-        assertEquals("", p.l2tpSecret);
-        assertEquals("", p.ipsecIdentifier);
-        assertEquals("", p.ipsecSecret);
-        assertEquals("", p.ipsecUserCert);
-        assertEquals("", p.ipsecCaCert);
-        assertEquals("", p.ipsecServerCert);
-        assertEquals(null, p.proxy);
-        assertTrue(p.getAllowedAlgorithms() != null && p.getAllowedAlgorithms().isEmpty());
-        assertFalse(p.isBypassable);
-        assertFalse(p.isMetered);
-        assertEquals(1360, p.maxMtu);
-        assertFalse(p.areAuthParamsInline);
-        assertFalse(p.isRestrictedToTestNetworks);
-        assertFalse(p.excludeLocalRoutes);
-        assertFalse(p.requiresInternetValidation);
-        assertFalse(p.automaticNattKeepaliveTimerEnabled);
-        assertFalse(p.automaticIpVersionSelectionEnabled);
-    }
-
-    private VpnProfile getSampleIkev2Profile(String key) {
-        final VpnProfile p = new VpnProfile(key, true /* isRestrictedToTestNetworks */,
-                false /* excludesLocalRoutes */, true /* requiresPlatformValidation */,
-                null /* ikeTunConnParams */, true /* mAutomaticNattKeepaliveTimerEnabled */,
-                true /* automaticIpVersionSelectionEnabled */);
-
-        p.name = "foo";
-        p.type = VpnProfile.TYPE_IKEV2_IPSEC_USER_PASS;
-        p.server = "bar";
-        p.username = "baz";
-        p.password = "qux";
-        p.dnsServers = "8.8.8.8";
-        p.searchDomains = "";
-        p.routes = "0.0.0.0/0";
-        p.mppe = false;
-        p.l2tpSecret = "";
-        p.ipsecIdentifier = "quux";
-        p.ipsecSecret = "quuz";
-        p.ipsecUserCert = "corge";
-        p.ipsecCaCert = "grault";
-        p.ipsecServerCert = "garply";
-        p.proxy = null;
-        p.setAllowedAlgorithms(
-                Arrays.asList(
-                        IpSecAlgorithm.AUTH_CRYPT_AES_GCM,
-                        IpSecAlgorithm.AUTH_CRYPT_CHACHA20_POLY1305,
-                        IpSecAlgorithm.AUTH_HMAC_SHA512,
-                        IpSecAlgorithm.CRYPT_AES_CBC));
-        p.isBypassable = true;
-        p.isMetered = true;
-        p.maxMtu = 1350;
-        p.areAuthParamsInline = true;
-
-        // Not saved, but also not compared.
-        p.saveLogin = true;
-
-        return p;
-    }
-
-    private VpnProfile getSampleIkev2ProfileWithIkeTunConnParams(String key) {
-        final VpnProfile p = new VpnProfile(key, true /* isRestrictedToTestNetworks */,
-                false /* excludesLocalRoutes */, true /* requiresPlatformValidation */,
-                new IkeTunnelConnectionParams(IKE_PARAMS_V4, CHILD_PARAMS),
-                true /* mAutomaticNattKeepaliveTimerEnabled */,
-                true /* automaticIpVersionSelectionEnabled */);
-
-        p.name = "foo";
-        p.server = "bar";
-        p.dnsServers = "8.8.8.8";
-        p.searchDomains = "";
-        p.routes = "0.0.0.0/0";
-        p.mppe = false;
-        p.proxy = null;
-        p.setAllowedAlgorithms(
-                Arrays.asList(
-                        IpSecAlgorithm.AUTH_CRYPT_AES_GCM,
-                        IpSecAlgorithm.AUTH_CRYPT_CHACHA20_POLY1305,
-                        IpSecAlgorithm.AUTH_HMAC_SHA512,
-                        IpSecAlgorithm.CRYPT_AES_CBC));
-        p.isBypassable = true;
-        p.isMetered = true;
-        p.maxMtu = 1350;
-        p.areAuthParamsInline = true;
-
-        // Not saved, but also not compared.
-        p.saveLogin = true;
-
-        return p;
-    }
-
-    @Test
-    public void testEquals() {
-        assertEquals(
-                getSampleIkev2Profile(DUMMY_PROFILE_KEY), getSampleIkev2Profile(DUMMY_PROFILE_KEY));
-
-        final VpnProfile modified = getSampleIkev2Profile(DUMMY_PROFILE_KEY);
-        modified.maxMtu--;
-        assertNotEquals(getSampleIkev2Profile(DUMMY_PROFILE_KEY), modified);
-    }
-
-    @Test
-    public void testParcelUnparcel() {
-        if (isAtLeastU()) {
-            // automaticNattKeepaliveTimerEnabled, automaticIpVersionSelectionEnabled added in U.
-            assertParcelSane(getSampleIkev2Profile(DUMMY_PROFILE_KEY), 28);
-            assertParcelSane(getSampleIkev2ProfileWithIkeTunConnParams(DUMMY_PROFILE_KEY), 28);
-        } else if (isAtLeastT()) {
-            // excludeLocalRoutes, requiresPlatformValidation were added in T.
-            assertParcelSane(getSampleIkev2Profile(DUMMY_PROFILE_KEY), 26);
-            assertParcelSane(getSampleIkev2ProfileWithIkeTunConnParams(DUMMY_PROFILE_KEY), 26);
-        } else {
-            assertParcelSane(getSampleIkev2Profile(DUMMY_PROFILE_KEY), 23);
-        }
-    }
-
-    @Test
-    public void testEncodeDecodeWithIkeTunConnParams() {
-        final VpnProfile profile = getSampleIkev2ProfileWithIkeTunConnParams(DUMMY_PROFILE_KEY);
-        final VpnProfile decoded = VpnProfile.decode(DUMMY_PROFILE_KEY, profile.encode());
-        assertEquals(profile, decoded);
-    }
-
-    @Test
-    public void testEncodeDecode() {
-        final VpnProfile profile = getSampleIkev2Profile(DUMMY_PROFILE_KEY);
-        final VpnProfile decoded = VpnProfile.decode(DUMMY_PROFILE_KEY, profile.encode());
-        assertEquals(profile, decoded);
-    }
-
-    @Test
-    public void testEncodeDecodeTooManyValues() {
-        final VpnProfile profile = getSampleIkev2Profile(DUMMY_PROFILE_KEY);
-        final byte[] tooManyValues =
-                (new String(profile.encode()) + VpnProfile.VALUE_DELIMITER + "invalid").getBytes();
-
-        assertNull(VpnProfile.decode(DUMMY_PROFILE_KEY, tooManyValues));
-    }
-
-    private String getEncodedDecodedIkev2ProfileMissingValues(int... missingIndices) {
-        // Sort to ensure when we remove, we can do it from greatest first.
-        Arrays.sort(missingIndices);
-
-        final String encoded = new String(getSampleIkev2Profile(DUMMY_PROFILE_KEY).encode());
-        final List<String> parts =
-                new ArrayList<>(Arrays.asList(encoded.split(VpnProfile.VALUE_DELIMITER)));
-
-        // Remove from back first to ensure indexing is consistent.
-        for (int i = missingIndices.length - 1; i >= 0; i--) {
-            parts.remove(missingIndices[i]);
-        }
-
-        return String.join(VpnProfile.VALUE_DELIMITER, parts.toArray(new String[0]));
-    }
-
-    @Test
-    public void testEncodeDecodeInvalidNumberOfValues() {
-        final String tooFewValues =
-                getEncodedDecodedIkev2ProfileMissingValues(
-                        ENCODED_INDEX_AUTH_PARAMS_INLINE,
-                        ENCODED_INDEX_RESTRICTED_TO_TEST_NETWORKS,
-                        ENCODED_INDEX_EXCLUDE_LOCAL_ROUTE,
-                        ENCODED_INDEX_REQUIRE_PLATFORM_VALIDATION,
-                        ENCODED_INDEX_IKE_TUN_CONN_PARAMS,
-                        ENCODED_INDEX_AUTOMATIC_NATT_KEEPALIVE_TIMER_ENABLED,
-                        ENCODED_INDEX_AUTOMATIC_IP_VERSION_SELECTION_ENABLED
-                        /* missingIndices */);
-
-        assertNull(VpnProfile.decode(DUMMY_PROFILE_KEY, tooFewValues.getBytes()));
-    }
-
-    private String getEncodedDecodedIkev2ProfileWithtooFewValues() {
-        return getEncodedDecodedIkev2ProfileMissingValues(
-                ENCODED_INDEX_RESTRICTED_TO_TEST_NETWORKS,
-                ENCODED_INDEX_EXCLUDE_LOCAL_ROUTE,
-                ENCODED_INDEX_REQUIRE_PLATFORM_VALIDATION,
-                ENCODED_INDEX_IKE_TUN_CONN_PARAMS,
-                ENCODED_INDEX_AUTOMATIC_NATT_KEEPALIVE_TIMER_ENABLED,
-                ENCODED_INDEX_AUTOMATIC_IP_VERSION_SELECTION_ENABLED /* missingIndices */);
-    }
-
-    @Test
-    public void testEncodeDecodeMissingIsRestrictedToTestNetworks() {
-        final String tooFewValues = getEncodedDecodedIkev2ProfileWithtooFewValues();
-
-        // Verify decoding without isRestrictedToTestNetworks defaults to false
-        final VpnProfile decoded = VpnProfile.decode(DUMMY_PROFILE_KEY, tooFewValues.getBytes());
-        assertFalse(decoded.isRestrictedToTestNetworks);
-    }
-
-    @Test
-    public void testEncodeDecodeMissingExcludeLocalRoutes() {
-        final String tooFewValues = getEncodedDecodedIkev2ProfileWithtooFewValues();
-
-        // Verify decoding without excludeLocalRoutes defaults to false
-        final VpnProfile decoded = VpnProfile.decode(DUMMY_PROFILE_KEY, tooFewValues.getBytes());
-        assertFalse(decoded.excludeLocalRoutes);
-    }
-
-    @Test
-    public void testEncodeDecodeMissingRequiresValidation() {
-        final String tooFewValues = getEncodedDecodedIkev2ProfileWithtooFewValues();
-
-        // Verify decoding without requiresValidation defaults to false
-        final VpnProfile decoded = VpnProfile.decode(DUMMY_PROFILE_KEY, tooFewValues.getBytes());
-        assertFalse(decoded.requiresInternetValidation);
-    }
-
-    @Test
-    public void testEncodeDecodeMissingAutomaticNattKeepaliveTimerEnabled() {
-        final String tooFewValues = getEncodedDecodedIkev2ProfileWithtooFewValues();
-
-        // Verify decoding without automaticNattKeepaliveTimerEnabled defaults to false
-        final VpnProfile decoded = VpnProfile.decode(DUMMY_PROFILE_KEY, tooFewValues.getBytes());
-        assertFalse(decoded.automaticNattKeepaliveTimerEnabled);
-    }
-
-    @Test
-    public void testEncodeDecodeMissingAutomaticIpVersionSelectionEnabled() {
-        final String tooFewValues = getEncodedDecodedIkev2ProfileWithtooFewValues();
-
-        // Verify decoding without automaticIpVersionSelectionEnabled defaults to false
-        final VpnProfile decoded = VpnProfile.decode(DUMMY_PROFILE_KEY, tooFewValues.getBytes());
-        assertFalse(decoded.automaticIpVersionSelectionEnabled);
-    }
-
-    @Test
-    public void testEncodeDecodeLoginsNotSaved() {
-        final VpnProfile profile = getSampleIkev2Profile(DUMMY_PROFILE_KEY);
-        profile.saveLogin = false;
-
-        final VpnProfile decoded = VpnProfile.decode(DUMMY_PROFILE_KEY, profile.encode());
-        assertNotEquals(profile, decoded);
-
-        // Add the username/password back, everything else must be equal.
-        decoded.username = profile.username;
-        decoded.password = profile.password;
-        assertEquals(profile, decoded);
-    }
-
-    @Test
-    public void testClone() {
-        final VpnProfile profile = getSampleIkev2Profile(DUMMY_PROFILE_KEY);
-        final VpnProfile clone = profile.clone();
-        assertEquals(profile, clone);
-        assertNotSame(profile, clone);
-    }
-}
diff --git a/tests/unit/java/com/android/metrics/ConnectivitySampleMetricsTest.kt b/tests/unit/java/com/android/metrics/ConnectivitySampleMetricsTest.kt
index 3043d50..8a9286f 100644
--- a/tests/unit/java/com/android/metrics/ConnectivitySampleMetricsTest.kt
+++ b/tests/unit/java/com/android/metrics/ConnectivitySampleMetricsTest.kt
@@ -1,5 +1,6 @@
 package com.android.metrics
 
+import android.net.ConnectivityThread
 import android.net.NetworkCapabilities
 import android.net.NetworkCapabilities.CONNECTIVITY_MANAGED_CAPABILITIES
 import android.net.NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL
@@ -15,12 +16,20 @@
 import android.net.NetworkCapabilities.NET_ENTERPRISE_ID_3
 import android.net.NetworkCapabilities.TRANSPORT_CELLULAR
 import android.net.NetworkCapabilities.TRANSPORT_WIFI
+import android.net.NetworkRequest
 import android.net.NetworkScore
+import android.net.NetworkScore.KEEP_CONNECTED_FOR_TEST
 import android.net.NetworkScore.POLICY_EXITING
 import android.net.NetworkScore.POLICY_TRANSPORT_PRIMARY
 import android.os.Build
 import android.os.Handler
+import android.os.Process
+import android.os.Process.SYSTEM_UID
 import android.stats.connectivity.MeteredState
+import android.stats.connectivity.RequestType
+import android.stats.connectivity.RequestType.RT_APP
+import android.stats.connectivity.RequestType.RT_SYSTEM
+import android.stats.connectivity.RequestType.RT_SYSTEM_ON_BEHALF_OF_APP
 import android.stats.connectivity.ValidatedState
 import androidx.test.filters.SmallTest
 import com.android.net.module.util.BitUtils
@@ -30,11 +39,13 @@
 import com.android.server.connectivity.FullScore.POLICY_IS_UNMETERED
 import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo
 import com.android.testutils.DevSdkIgnoreRunner
-import org.junit.Test
-import org.junit.runner.RunWith
+import com.android.testutils.TestableNetworkCallback
 import java.util.concurrent.CompletableFuture
 import kotlin.test.assertEquals
 import kotlin.test.fail
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
 
 private fun <T> Handler.onHandler(f: () -> T): T {
     val future = CompletableFuture<T>()
@@ -79,14 +90,17 @@
 @IgnoreUpTo(Build.VERSION_CODES.TIRAMISU)
 class ConnectivitySampleMetricsTest : CSTest() {
     @Test
-    fun testSampleConnectivityState() {
+    fun testSampleConnectivityState_Network() {
         val wifi1Caps = NetworkCapabilities.Builder()
                 .addTransportType(TRANSPORT_WIFI)
                 .addCapability(NET_CAPABILITY_NOT_METERED)
                 .addCapability(NET_CAPABILITY_NOT_SUSPENDED)
                 .addCapability(NET_CAPABILITY_NOT_ROAMING)
                 .build()
-        val wifi1Score = NetworkScore.Builder().setExiting(true).build()
+        val wifi1Score = NetworkScore.Builder()
+                .setKeepConnectedReason(KEEP_CONNECTED_FOR_TEST)
+                .setExiting(true)
+                .build()
         val agentWifi1 = Agent(nc = wifi1Caps, score = FromS(wifi1Score)).also { it.connect() }
 
         val wifi2Caps = NetworkCapabilities.Builder()
@@ -96,7 +110,10 @@
                 .addCapability(NET_CAPABILITY_NOT_ROAMING)
                 .addEnterpriseId(NET_ENTERPRISE_ID_3)
                 .build()
-        val wifi2Score = NetworkScore.Builder().setTransportPrimary(true).build()
+        val wifi2Score = NetworkScore.Builder()
+                .setKeepConnectedReason(KEEP_CONNECTED_FOR_TEST)
+                .setTransportPrimary(true)
+                .build()
         val agentWifi2 = Agent(nc = wifi2Caps, score = FromS(wifi2Score)).also { it.connect() }
 
         val cellCaps = NetworkCapabilities.Builder()
@@ -107,7 +124,9 @@
                 .addCapability(NET_CAPABILITY_NOT_ROAMING)
                 .addEnterpriseId(NET_ENTERPRISE_ID_1)
                 .build()
-        val cellScore = NetworkScore.Builder().build()
+        val cellScore = NetworkScore.Builder()
+                .setKeepConnectedReason(KEEP_CONNECTED_FOR_TEST)
+                .build()
         val agentCell = Agent(nc = cellCaps, score = FromS(cellScore)).also { it.connect() }
 
         val stats = csHandler.onHandler { service.sampleConnectivityState() }
@@ -170,4 +189,61 @@
                         "expected ${expectedWifi2Policies.toPolicyString()}, " +
                         "found ${foundWifi2.scorePolicies.toPolicyString()}")
     }
+
+    private fun fileNetworkRequest(requestType: RequestType, requestCount: Int, uid: Int? = null) {
+        if (uid != null) {
+            deps.setCallingUid(uid)
+        }
+        try {
+            repeat(requestCount) {
+                when (requestType) {
+                    RT_APP, RT_SYSTEM -> cm.requestNetwork(
+                            NetworkRequest.Builder().build(),
+                            TestableNetworkCallback()
+                    )
+
+                    RT_SYSTEM_ON_BEHALF_OF_APP -> cm.registerDefaultNetworkCallbackForUid(
+                            Process.myUid(),
+                            TestableNetworkCallback(),
+                            Handler(ConnectivityThread.getInstanceLooper()))
+
+                    else -> fail("invalid requestType: " + requestType)
+                }
+            }
+        } finally {
+            deps.unmockCallingUid()
+        }
+    }
+
+
+    @Test
+    fun testSampleConnectivityState_NetworkRequest() {
+        val requestCount = 5
+        fileNetworkRequest(RT_APP, requestCount);
+        fileNetworkRequest(RT_SYSTEM, requestCount, SYSTEM_UID);
+        fileNetworkRequest(RT_SYSTEM_ON_BEHALF_OF_APP, requestCount, SYSTEM_UID);
+
+        val stats = csHandler.onHandler { service.sampleConnectivityState() }
+
+        assertEquals(3, stats.networkRequestCount.requestCountForTypeList.size)
+        val appRequest = stats.networkRequestCount.requestCountForTypeList.find {
+            it.requestType == RT_APP
+        } ?: fail("Can't find RT_APP request")
+        val systemRequest = stats.networkRequestCount.requestCountForTypeList.find {
+            it.requestType == RT_SYSTEM
+        } ?: fail("Can't find RT_SYSTEM request")
+        val systemOnBehalfOfAppRequest = stats.networkRequestCount.requestCountForTypeList.find {
+            it.requestType == RT_SYSTEM_ON_BEHALF_OF_APP
+        } ?: fail("Can't find RT_SYSTEM_ON_BEHALF_OF_APP request")
+
+        // Verify request count is equal or larger than the number of request this test filed
+        // since ConnectivityService internally files network requests
+        assertTrue("Unexpected RT_APP count, expected >= $requestCount, " +
+                "found ${appRequest.requestCount}", appRequest.requestCount >= requestCount)
+        assertTrue("Unexpected RT_SYSTEM count, expected >= $requestCount, " +
+                "found ${systemRequest.requestCount}", systemRequest.requestCount >= requestCount)
+        assertTrue("Unexpected RT_SYSTEM_ON_BEHALF_OF_APP count, expected >= $requestCount, " +
+                "found ${systemOnBehalfOfAppRequest.requestCount}",
+                systemOnBehalfOfAppRequest.requestCount >= requestCount)
+    }
 }
diff --git a/tests/unit/java/com/android/server/BpfLoaderRcUtilsTest.kt b/tests/unit/java/com/android/server/BpfLoaderRcUtilsTest.kt
deleted file mode 100644
index 2cf6b17..0000000
--- a/tests/unit/java/com/android/server/BpfLoaderRcUtilsTest.kt
+++ /dev/null
@@ -1,72 +0,0 @@
-/*
- * 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.os.Build
-import androidx.test.filters.SmallTest
-import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo
-import com.android.testutils.DevSdkIgnoreRunner
-import kotlin.test.assertEquals
-import kotlin.test.assertTrue
-import org.junit.Test
-import org.junit.runner.RunWith
-
-@RunWith(DevSdkIgnoreRunner::class)
-@SmallTest
-@IgnoreUpTo(Build.VERSION_CODES.S)
-class BpfLoaderRcUtilsTest {
-    @Test
-    fun testLoadExistingBpfRcFile() {
-
-        val inputString = """
-            service a
-            # test comment
-            service bpfloader /system/bin/bpfloader
-                capabilities CHOWN SYS_ADMIN NET_ADMIN
-                group root graphics network_stack net_admin net_bw_acct net_bw_stats net_raw system
-                user root
-                rlimit memlock 1073741824 1073741824
-                oneshot
-                # comment 漢字
-                reboot_on_failure reboot,bpfloader-failed
-                updatable
-            
-            #test comment
-            on b 
-              oneshot 
-              # test comment
-        """.trimIndent()
-        val expectedResult = listOf(
-            "service bpfloader /system/bin/bpfloader",
-            "capabilities CHOWN SYS_ADMIN NET_ADMIN",
-            "group root graphics network_stack net_admin net_bw_acct net_bw_stats net_raw system",
-            "user root",
-            "rlimit memlock 1073741824 1073741824",
-            "oneshot",
-            "reboot_on_failure reboot,bpfloader-failed",
-            "updatable"
-        )
-
-        assertEquals(expectedResult,
-                BpfLoaderRcUtils.loadExistingBpfRcFile(inputString.byteInputStream()))
-    }
-
-    @Test
-    fun testCheckBpfRcFile() {
-        assertTrue(BpfLoaderRcUtils.checkBpfLoaderRc())
-    }
-}
diff --git a/tests/unit/java/com/android/server/ConnectivityServiceTest.java b/tests/unit/java/com/android/server/ConnectivityServiceTest.java
index c534025..7822fe0 100755
--- a/tests/unit/java/com/android/server/ConnectivityServiceTest.java
+++ b/tests/unit/java/com/android/server/ConnectivityServiceTest.java
@@ -162,7 +162,6 @@
 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.LOG_BPF_RC;
 import static com.android.server.ConnectivityService.MAX_NETWORK_REQUESTS_PER_SYSTEM_UID;
 import static com.android.server.ConnectivityService.PREFERENCE_ORDER_MOBILE_DATA_PREFERERRED;
 import static com.android.server.ConnectivityService.PREFERENCE_ORDER_OEM;
@@ -173,6 +172,8 @@
 import static com.android.server.ConnectivityServiceTestUtils.transportToLegacyType;
 import static com.android.server.NetworkAgentWrapper.CallbackType.OnQosCallbackRegister;
 import static com.android.server.NetworkAgentWrapper.CallbackType.OnQosCallbackUnregister;
+import static com.android.server.connectivity.ConnectivityFlags.BACKGROUND_FIREWALL_CHAIN;
+import static com.android.server.connectivity.ConnectivityFlags.INGRESS_TO_VPN_ADDRESS_FILTERING;
 import static com.android.testutils.Cleanup.testAndCleanup;
 import static com.android.testutils.ConcurrentUtils.await;
 import static com.android.testutils.ConcurrentUtils.durationOf;
@@ -368,7 +369,6 @@
 import android.os.UserHandle;
 import android.os.UserManager;
 import android.provider.Settings;
-import android.security.Credentials;
 import android.system.Os;
 import android.telephony.SubscriptionManager;
 import android.telephony.TelephonyManager;
@@ -389,10 +389,10 @@
 import com.android.internal.annotations.GuardedBy;
 import com.android.internal.app.IBatteryStats;
 import com.android.internal.net.VpnConfig;
-import com.android.internal.net.VpnProfile;
 import com.android.internal.util.WakeupMessage;
 import com.android.internal.util.test.BroadcastInterceptingContext;
 import com.android.internal.util.test.FakeSettingsProvider;
+import com.android.modules.utils.build.SdkLevel;
 import com.android.net.module.util.ArrayTrackRecord;
 import com.android.net.module.util.BaseNetdUnsolicitedEventListener;
 import com.android.net.module.util.CollectionUtils;
@@ -424,7 +424,6 @@
 import com.android.server.connectivity.SatelliteAccessController;
 import com.android.server.connectivity.TcpKeepaliveController;
 import com.android.server.connectivity.UidRangeUtils;
-import com.android.server.connectivity.VpnProfileStore;
 import com.android.server.net.NetworkPinner;
 import com.android.testutils.DevSdkIgnoreRule;
 import com.android.testutils.DevSdkIgnoreRunner;
@@ -464,7 +463,6 @@
 import java.net.InetAddress;
 import java.net.InetSocketAddress;
 import java.net.Socket;
-import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
@@ -631,7 +629,6 @@
     @Mock TelephonyManager mTelephonyManager;
     @Mock EthernetManager mEthernetManager;
     @Mock NetworkPolicyManager mNetworkPolicyManager;
-    @Mock VpnProfileStore mVpnProfileStore;
     @Mock SystemConfigManager mSystemConfigManager;
     @Mock DevicePolicyManager mDevicePolicyManager;
     @Mock Resources mResources;
@@ -1667,23 +1664,11 @@
             waitForIdle();
         }
 
-        public void startLegacyVpnPrivileged(VpnProfile profile) {
-            switch (profile.type) {
-                case VpnProfile.TYPE_IKEV2_IPSEC_RSA:
-                case VpnProfile.TYPE_IKEV2_IPSEC_USER_PASS:
-                case VpnProfile.TYPE_IKEV2_IPSEC_PSK:
-                case VpnProfile.TYPE_IKEV2_FROM_IKE_TUN_CONN_PARAMS:
-                    startPlatformVpn();
-                    break;
-                case VpnProfile.TYPE_L2TP_IPSEC_PSK:
-                case VpnProfile.TYPE_L2TP_IPSEC_RSA:
-                case VpnProfile.TYPE_IPSEC_XAUTH_PSK:
-                case VpnProfile.TYPE_IPSEC_XAUTH_RSA:
-                case VpnProfile.TYPE_IPSEC_HYBRID_RSA:
-                    startLegacyVpn();
-                    break;
-                default:
-                    fail("Unknown VPN profile type");
+        public void startLegacyVpnPrivileged(boolean isIkev2Vpn) {
+            if (isIkev2Vpn) {
+                startPlatformVpn();
+            } else {
+                startLegacyVpn();
             }
         }
 
@@ -1736,6 +1721,8 @@
     private void mockUidNetworkingBlocked() {
         doAnswer(i -> isUidBlocked(mBlockedReasons, i.getArgument(1))
         ).when(mNetworkPolicyManager).isUidNetworkingBlocked(anyInt(), anyBoolean());
+        doAnswer(i -> isUidBlocked(mBlockedReasons, i.getArgument(1))
+        ).when(mBpfNetMaps).isUidNetworkingBlocked(anyInt(), anyBoolean());
     }
 
     private boolean isUidBlocked(int blockedReasons, boolean meteredNetwork) {
@@ -2055,12 +2042,16 @@
             };
         }
 
+        private BiConsumer<Integer, Integer> mCarrierPrivilegesLostListener;
+
         @Override
         public CarrierPrivilegeAuthenticator makeCarrierPrivilegeAuthenticator(
                 @NonNull final Context context,
                 @NonNull final TelephonyManager tm,
                 final boolean requestRestrictedWifiEnabled,
-                BiConsumer<Integer, Integer> listener) {
+                BiConsumer<Integer, Integer> listener,
+                @NonNull final Handler handler) {
+            mCarrierPrivilegesLostListener = listener;
             return mDeps.isAtLeastT() ? mCarrierPrivilegeAuthenticator : null;
         }
 
@@ -2178,10 +2169,12 @@
             switch (name) {
                 case ALLOW_SYSUI_CONNECTIVITY_REPORTS:
                     return true;
-                case LOG_BPF_RC:
-                    return true;
                 case ALLOW_SATALLITE_NETWORK_FALLBACK:
                     return true;
+                case INGRESS_TO_VPN_ADDRESS_FILTERING:
+                    return true;
+                case BACKGROUND_FIREWALL_CHAIN:
+                    return true;
                 default:
                     return super.isFeatureNotChickenedOut(context, name);
             }
@@ -10213,24 +10206,6 @@
         doAsUid(Process.SYSTEM_UID, () -> mCm.unregisterNetworkCallback(perUidCb));
     }
 
-    private VpnProfile setupLockdownVpn(int profileType) {
-        final String profileName = "testVpnProfile";
-        final byte[] profileTag = profileName.getBytes(StandardCharsets.UTF_8);
-        doReturn(profileTag).when(mVpnProfileStore).get(Credentials.LOCKDOWN_VPN);
-
-        final VpnProfile profile = new VpnProfile(profileName);
-        profile.name = "My VPN";
-        profile.server = "192.0.2.1";
-        profile.dnsServers = "8.8.8.8";
-        profile.ipsecIdentifier = "My ipsecIdentifier";
-        profile.ipsecSecret = "My PSK";
-        profile.type = profileType;
-        final byte[] encodedProfile = profile.encode();
-        doReturn(encodedProfile).when(mVpnProfileStore).get(Credentials.VPN + profileName);
-
-        return profile;
-    }
-
     private void establishLegacyLockdownVpn(Network underlying) throws Exception {
         // The legacy lockdown VPN only supports userId 0, and must have an underlying network.
         assertNotNull(underlying);
@@ -10242,7 +10217,7 @@
         mMockVpn.connect(true);
     }
 
-    private void doTestLockdownVpn(VpnProfile profile, boolean expectSetVpnDefaultForUids)
+    private void doTestLockdownVpn(boolean isIkev2Vpn)
             throws Exception {
         mServiceContext.setPermission(
                 Manifest.permission.CONTROL_VPN, PERMISSION_GRANTED);
@@ -10280,8 +10255,8 @@
         b.expectBroadcast();
         // Simulate LockdownVpnTracker attempting to start the VPN since it received the
         // systemDefault callback.
-        mMockVpn.startLegacyVpnPrivileged(profile);
-        if (expectSetVpnDefaultForUids) {
+        mMockVpn.startLegacyVpnPrivileged(isIkev2Vpn);
+        if (isIkev2Vpn) {
             // setVpnDefaultForUids() releases the original network request and creates a VPN
             // request so LOST callback is received.
             defaultCallback.expect(LOST, mCellAgent);
@@ -10305,7 +10280,7 @@
         final NetworkCapabilities vpnNc = mCm.getNetworkCapabilities(mMockVpn.getNetwork());
         b2.expectBroadcast();
         b3.expectBroadcast();
-        if (expectSetVpnDefaultForUids) {
+        if (isIkev2Vpn) {
             // Due to the VPN default request, getActiveNetworkInfo() gets the VPN network as the
             // network satisfier which has TYPE_VPN.
             assertActiveNetworkInfo(TYPE_VPN, DetailedState.CONNECTED);
@@ -10351,14 +10326,15 @@
         // callback with different network.
         final ExpectedBroadcast b6 = expectConnectivityAction(TYPE_VPN, DetailedState.DISCONNECTED);
         mMockVpn.stopVpnRunnerPrivileged();
-        mMockVpn.startLegacyVpnPrivileged(profile);
+
+        mMockVpn.startLegacyVpnPrivileged(isIkev2Vpn);
         // VPN network is disconnected (to restart)
         callback.expect(LOST, mMockVpn);
         defaultCallback.expect(LOST, mMockVpn);
         // The network preference is cleared when VPN is disconnected so it receives callbacks for
         // the system-wide default.
         defaultCallback.expectAvailableCallbacksUnvalidatedAndBlocked(mWiFiAgent);
-        if (expectSetVpnDefaultForUids) {
+        if (isIkev2Vpn) {
             // setVpnDefaultForUids() releases the original network request and creates a VPN
             // request so LOST callback is received.
             defaultCallback.expect(LOST, mWiFiAgent);
@@ -10367,7 +10343,7 @@
         b6.expectBroadcast();
 
         // While the VPN is reconnecting on the new network, everything is blocked.
-        if (expectSetVpnDefaultForUids) {
+        if (isIkev2Vpn) {
             // Due to the VPN default request, getActiveNetworkInfo() gets the mNoServiceNetwork
             // as the network satisfier.
             assertNull(mCm.getActiveNetworkInfo());
@@ -10388,7 +10364,7 @@
         systemDefaultCallback.assertNoCallback();
         b7.expectBroadcast();
         b8.expectBroadcast();
-        if (expectSetVpnDefaultForUids) {
+        if (isIkev2Vpn) {
             // Due to the VPN default request, getActiveNetworkInfo() gets the VPN network as the
             // network satisfier which has TYPE_VPN.
             assertActiveNetworkInfo(TYPE_VPN, DetailedState.CONNECTED);
@@ -10414,7 +10390,7 @@
         defaultCallback.assertNoCallback();
         systemDefaultCallback.assertNoCallback();
 
-        if (expectSetVpnDefaultForUids) {
+        if (isIkev2Vpn) {
             // Due to the VPN default request, getActiveNetworkInfo() gets the VPN network as the
             // network satisfier which has TYPE_VPN.
             assertActiveNetworkInfo(TYPE_VPN, DetailedState.CONNECTED);
@@ -10455,14 +10431,12 @@
 
     @Test
     public void testLockdownVpn_LegacyVpnRunner() throws Exception {
-        final VpnProfile profile = setupLockdownVpn(VpnProfile.TYPE_IPSEC_XAUTH_PSK);
-        doTestLockdownVpn(profile, false /* expectSetVpnDefaultForUids */);
+        doTestLockdownVpn(false /* isIkev2Vpn */);
     }
 
     @Test
     public void testLockdownVpn_Ikev2VpnRunner() throws Exception {
-        final VpnProfile profile = setupLockdownVpn(VpnProfile.TYPE_IKEV2_IPSEC_PSK);
-        doTestLockdownVpn(profile, true /* expectSetVpnDefaultForUids */);
+        doTestLockdownVpn(true /* isIkev2Vpn */);
     }
 
     @Test @IgnoreUpTo(Build.VERSION_CODES.S_V2)
@@ -10518,7 +10492,10 @@
         doTestSetUidFirewallRule(FIREWALL_CHAIN_POWERSAVE, FIREWALL_RULE_DENY);
         doTestSetUidFirewallRule(FIREWALL_CHAIN_RESTRICTED, FIREWALL_RULE_DENY);
         doTestSetUidFirewallRule(FIREWALL_CHAIN_LOW_POWER_STANDBY, FIREWALL_RULE_DENY);
-        doTestSetUidFirewallRule(FIREWALL_CHAIN_BACKGROUND, FIREWALL_RULE_DENY);
+        if (SdkLevel.isAtLeastV()) {
+            // FIREWALL_CHAIN_BACKGROUND is only available on V+.
+            doTestSetUidFirewallRule(FIREWALL_CHAIN_BACKGROUND, FIREWALL_RULE_DENY);
+        }
         doTestSetUidFirewallRule(FIREWALL_CHAIN_OEM_DENY_1, FIREWALL_RULE_ALLOW);
         doTestSetUidFirewallRule(FIREWALL_CHAIN_OEM_DENY_2, FIREWALL_RULE_ALLOW);
         doTestSetUidFirewallRule(FIREWALL_CHAIN_OEM_DENY_3, FIREWALL_RULE_ALLOW);
@@ -10526,16 +10503,19 @@
 
     @Test @IgnoreUpTo(SC_V2)
     public void testSetFirewallChainEnabled() throws Exception {
-        final List<Integer> firewallChains = Arrays.asList(
+        final List<Integer> firewallChains = new ArrayList<>(Arrays.asList(
                 FIREWALL_CHAIN_DOZABLE,
                 FIREWALL_CHAIN_STANDBY,
                 FIREWALL_CHAIN_POWERSAVE,
                 FIREWALL_CHAIN_RESTRICTED,
                 FIREWALL_CHAIN_LOW_POWER_STANDBY,
-                FIREWALL_CHAIN_BACKGROUND,
                 FIREWALL_CHAIN_OEM_DENY_1,
                 FIREWALL_CHAIN_OEM_DENY_2,
-                FIREWALL_CHAIN_OEM_DENY_3);
+                FIREWALL_CHAIN_OEM_DENY_3));
+        if (SdkLevel.isAtLeastV()) {
+            // FIREWALL_CHAIN_BACKGROUND is only available on V+.
+            firewallChains.add(FIREWALL_CHAIN_BACKGROUND);
+        }
         for (final int chain: firewallChains) {
             mCm.setFirewallChainEnabled(chain, true /* enabled */);
             verify(mBpfNetMaps).setChildChain(chain, true /* enable */);
@@ -10582,7 +10562,10 @@
         doTestSetFirewallChainEnabledCloseSocket(FIREWALL_CHAIN_POWERSAVE, allowlist);
         doTestSetFirewallChainEnabledCloseSocket(FIREWALL_CHAIN_RESTRICTED, allowlist);
         doTestSetFirewallChainEnabledCloseSocket(FIREWALL_CHAIN_LOW_POWER_STANDBY, allowlist);
-        doTestSetFirewallChainEnabledCloseSocket(FIREWALL_CHAIN_BACKGROUND, allowlist);
+        if (SdkLevel.isAtLeastV()) {
+            // FIREWALL_CHAIN_BACKGROUND is only available on V+.
+            doTestSetFirewallChainEnabledCloseSocket(FIREWALL_CHAIN_BACKGROUND, allowlist);
+        }
 
         doTestSetFirewallChainEnabledCloseSocket(FIREWALL_CHAIN_STANDBY, denylist);
         doTestSetFirewallChainEnabledCloseSocket(FIREWALL_CHAIN_OEM_DENY_1, denylist);
@@ -10604,7 +10587,10 @@
         doTestReplaceFirewallChain(FIREWALL_CHAIN_POWERSAVE);
         doTestReplaceFirewallChain(FIREWALL_CHAIN_RESTRICTED);
         doTestReplaceFirewallChain(FIREWALL_CHAIN_LOW_POWER_STANDBY);
-        doTestReplaceFirewallChain(FIREWALL_CHAIN_BACKGROUND);
+        if (SdkLevel.isAtLeastV()) {
+            // FIREWALL_CHAIN_BACKGROUND is only available on V+.
+            doTestReplaceFirewallChain(FIREWALL_CHAIN_BACKGROUND);
+        }
         doTestReplaceFirewallChain(FIREWALL_CHAIN_OEM_DENY_1);
         doTestReplaceFirewallChain(FIREWALL_CHAIN_OEM_DENY_2);
         doTestReplaceFirewallChain(FIREWALL_CHAIN_OEM_DENY_3);
@@ -17341,21 +17327,7 @@
     }
 
     @Test
-    public void testSubIdsClearedWithoutNetworkFactoryPermission() throws Exception {
-        mServiceContext.setPermission(NETWORK_FACTORY, PERMISSION_DENIED);
-        final NetworkCapabilities nc = new NetworkCapabilities();
-        nc.setSubscriptionIds(Collections.singleton(Process.myUid()));
-
-        final NetworkCapabilities result =
-                mService.networkCapabilitiesRestrictedForCallerPermissions(
-                        nc, Process.myPid(), Process.myUid());
-        assertTrue(result.getSubscriptionIds().isEmpty());
-    }
-
-    @Test
-    public void testSubIdsExistWithNetworkFactoryPermission() throws Exception {
-        mServiceContext.setPermission(NETWORK_FACTORY, PERMISSION_GRANTED);
-
+    public void testSubIdsExist() throws Exception {
         final Set<Integer> subIds = Collections.singleton(Process.myUid());
         final NetworkCapabilities nc = new NetworkCapabilities();
         nc.setSubscriptionIds(subIds);
@@ -17381,8 +17353,7 @@
     }
 
     @Test
-    public void testNetworkRequestWithSubIdsWithNetworkFactoryPermission() throws Exception {
-        mServiceContext.setPermission(NETWORK_FACTORY, PERMISSION_GRANTED);
+    public void testNetworkRequestWithSubIds() throws Exception {
         final PendingIntent pendingIntent = PendingIntent.getBroadcast(
                 mContext, 0 /* requestCode */, new Intent("a"), FLAG_IMMUTABLE);
         final NetworkCallback networkCallback1 = new NetworkCallback();
@@ -17398,21 +17369,6 @@
     }
 
     @Test
-    public void testNetworkRequestWithSubIdsWithoutNetworkFactoryPermission() throws Exception {
-        mServiceContext.setPermission(NETWORK_FACTORY, PERMISSION_DENIED);
-        final PendingIntent pendingIntent = PendingIntent.getBroadcast(
-                mContext, 0 /* requestCode */, new Intent("a"), FLAG_IMMUTABLE);
-
-        final Class<SecurityException> expected = SecurityException.class;
-        assertThrows(
-                expected, () -> mCm.requestNetwork(getRequestWithSubIds(), new NetworkCallback()));
-        assertThrows(expected, () -> mCm.requestNetwork(getRequestWithSubIds(), pendingIntent));
-        assertThrows(
-                expected,
-                () -> mCm.registerNetworkCallback(getRequestWithSubIds(), new NetworkCallback()));
-    }
-
-    @Test
     @IgnoreUpTo(Build.VERSION_CODES.TIRAMISU)
     public void testCarrierConfigAppSendNetworkRequestForRestrictedWifi() throws Exception {
         mServiceContext.setPermission(CONNECTIVITY_USE_RESTRICTED_NETWORKS, PERMISSION_DENIED);
@@ -17479,7 +17435,10 @@
                 .isCarrierServiceUidForNetworkCapabilities(eq(Process.myUid()), any());
         doReturn(TEST_SUBSCRIPTION_ID).when(mCarrierPrivilegeAuthenticator)
                 .getSubIdFromNetworkCapabilities(any());
-        mService.onCarrierPrivilegesLost(lostPrivilegeUid, lostPrivilegeSubId);
+
+        visibleOnHandlerThread(mCsHandlerThread.getThreadHandler(), () -> {
+            mDeps.mCarrierPrivilegesLostListener.accept(lostPrivilegeUid, lostPrivilegeSubId);
+        });
         waitForIdle();
 
         if (expectCapChanged) {
@@ -17493,11 +17452,12 @@
         }
 
         mWiFiAgent.disconnect();
-        waitForIdle();
 
         if (expectUnavailable) {
+            testFactory.expectRequestRemove();
             testFactory.assertRequestCountEquals(0);
         } else {
+            testFactory.expectRequestAdd();
             testFactory.assertRequestCountEquals(1);
         }
 
@@ -17547,6 +17507,47 @@
                 false /* expectUnavailable */,
                 true /* expectCapChanged */);
     }
+
+    @Test
+    public void testAllowedUidsExistWithoutNetworkFactoryPermission() throws Exception {
+        // Make sure NETWORK_FACTORY permission is not granted.
+        mServiceContext.setPermission(NETWORK_FACTORY, PERMISSION_DENIED);
+        mServiceContext.setPermission(MANAGE_TEST_NETWORKS, PERMISSION_GRANTED);
+        final TestNetworkCallback cb = new TestNetworkCallback();
+        mCm.requestNetwork(new NetworkRequest.Builder()
+                        .clearCapabilities()
+                        .addTransportType(TRANSPORT_TEST)
+                        .addTransportType(TRANSPORT_CELLULAR)
+                        .build(),
+                cb);
+
+        final ArraySet<Integer> uids = new ArraySet<>();
+        uids.add(200);
+        final NetworkCapabilities nc = new NetworkCapabilities.Builder()
+                .addTransportType(TRANSPORT_TEST)
+                .removeCapability(NET_CAPABILITY_NOT_RESTRICTED)
+                .setAllowedUids(uids)
+                .setOwnerUid(Process.myUid())
+                .setAdministratorUids(new int[] {Process.myUid()})
+                .build();
+        final TestNetworkAgentWrapper agent = new TestNetworkAgentWrapper(TRANSPORT_TEST,
+                new LinkProperties(), nc);
+        agent.connect(true);
+        cb.expectAvailableThenValidatedCallbacks(agent);
+
+        uids.add(300);
+        uids.add(400);
+        nc.setAllowedUids(uids);
+        agent.setNetworkCapabilities(nc, true /* sendToConnectivityService */);
+        if (mDeps.isAtLeastT()) {
+            // AllowedUids is not cleared even without the NETWORK_FACTORY permission
+            // because the caller is the owner of the network.
+            cb.expectCaps(agent, c -> c.getAllowedUids().equals(uids));
+        } else {
+            cb.assertNoCallback();
+        }
+    }
+
     @Test
     public void testAllowedUids() throws Exception {
         final int preferenceOrder =
diff --git a/tests/unit/java/com/android/server/NsdServiceTest.java b/tests/unit/java/com/android/server/NsdServiceTest.java
index 624855e..d91e29c 100644
--- a/tests/unit/java/com/android/server/NsdServiceTest.java
+++ b/tests/unit/java/com/android/server/NsdServiceTest.java
@@ -83,6 +83,7 @@
 import android.net.mdns.aidl.IMDnsEventListener;
 import android.net.mdns.aidl.RegistrationInfo;
 import android.net.mdns.aidl.ResolutionInfo;
+import android.net.nsd.AdvertisingRequest;
 import android.net.nsd.INsdManagerCallback;
 import android.net.nsd.INsdServiceConnector;
 import android.net.nsd.MDnsManager;
@@ -101,6 +102,7 @@
 import android.os.IBinder;
 import android.os.Looper;
 import android.os.Message;
+import android.os.Process;
 import android.os.RemoteException;
 import android.util.Pair;
 
@@ -110,6 +112,7 @@
 import com.android.metrics.NetworkNsdReportedMetrics;
 import com.android.server.NsdService.Dependencies;
 import com.android.server.connectivity.mdns.MdnsAdvertiser;
+import com.android.server.connectivity.mdns.MdnsAdvertisingOptions;
 import com.android.server.connectivity.mdns.MdnsDiscoveryManager;
 import com.android.server.connectivity.mdns.MdnsInterfaceSocket;
 import com.android.server.connectivity.mdns.MdnsSearchOptions;
@@ -137,6 +140,8 @@
 
 import java.net.InetAddress;
 import java.net.UnknownHostException;
+import java.time.Duration;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.LinkedList;
@@ -516,6 +521,56 @@
     }
 
     @Test
+    @EnableCompatChanges(ENABLE_PLATFORM_MDNS_BACKEND)
+    public void testDiscoverOnTetheringDownstream_DiscoveryManager() throws Exception {
+        final NsdManager client = connectClient(mService);
+        final DiscoveryListener discListener = mock(DiscoveryListener.class);
+        client.discoverServices(SERVICE_TYPE, PROTOCOL, discListener);
+        waitForIdle();
+
+        final ArgumentCaptor<MdnsServiceBrowserListener> discoverListenerCaptor =
+                ArgumentCaptor.forClass(MdnsServiceBrowserListener.class);
+        final InOrder discManagerOrder = inOrder(mDiscoveryManager);
+        final String serviceTypeWithLocalDomain = SERVICE_TYPE + ".local";
+        discManagerOrder.verify(mDiscoveryManager).registerListener(eq(serviceTypeWithLocalDomain),
+                discoverListenerCaptor.capture(), any());
+
+        final int interfaceIdx = 123;
+        final MdnsServiceInfo mockServiceInfo = new MdnsServiceInfo(
+                SERVICE_NAME, /* serviceInstanceName */
+                serviceTypeWithLocalDomain.split("\\."), /* serviceType */
+                List.of(), /* subtypes */
+                new String[] {"android", "local"}, /* hostName */
+                12345, /* port */
+                List.of(IPV4_ADDRESS),
+                List.of(IPV6_ADDRESS),
+                List.of(), /* textStrings */
+                List.of(), /* textEntries */
+                interfaceIdx, /* interfaceIndex */
+                null /* network */,
+                Instant.MAX /* expirationTime */);
+
+        // Verify service is found with the interface index
+        discoverListenerCaptor.getValue().onServiceNameDiscovered(
+                mockServiceInfo, false /* isServiceFromCache */);
+        final ArgumentCaptor<NsdServiceInfo> foundInfoCaptor =
+                ArgumentCaptor.forClass(NsdServiceInfo.class);
+        verify(discListener, timeout(TIMEOUT_MS)).onServiceFound(foundInfoCaptor.capture());
+        final NsdServiceInfo foundInfo = foundInfoCaptor.getValue();
+        assertNull(foundInfo.getNetwork());
+        assertEquals(interfaceIdx, foundInfo.getInterfaceIndex());
+
+        // Using the returned service info to resolve or register callback uses the interface index
+        client.resolveService(foundInfo, mock(ResolveListener.class));
+        client.registerServiceInfoCallback(foundInfo, Runnable::run,
+                mock(ServiceInfoCallback.class));
+        waitForIdle();
+
+        discManagerOrder.verify(mDiscoveryManager, times(2)).registerListener(any(), any(), argThat(
+                o -> o.getNetwork() == null && o.getInterfaceIndex() == interfaceIdx));
+    }
+
+    @Test
     @DisableCompatChanges(ENABLE_PLATFORM_MDNS_BACKEND)
     @DevSdkIgnoreRule.IgnoreAfter(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
     public void testDiscoverOnBlackholeNetwork() throws Exception {
@@ -971,7 +1026,8 @@
                 List.of() /* textStrings */,
                 List.of() /* textEntries */,
                 1234,
-                network);
+                network,
+                Instant.MAX /* expirationTime */);
 
         // Callbacks for query sent.
         listener.onDiscoveryQuerySent(Collections.emptyList(), 1 /* transactionId */);
@@ -1001,7 +1057,8 @@
                 List.of() /* textStrings */,
                 List.of() /* textEntries */,
                 1234,
-                network);
+                network,
+                Instant.MAX /* expirationTime */);
 
         // Verify onServiceUpdated callback.
         listener.onServiceUpdated(updatedServiceInfo);
@@ -1133,7 +1190,8 @@
                 List.of(), /* textStrings */
                 List.of(), /* textEntries */
                 1234, /* interfaceIndex */
-                network);
+                network,
+                Instant.MAX /* expirationTime */);
 
         // Verify onServiceNameDiscovered callback
         listener.onServiceNameDiscovered(foundInfo, false /* isServiceFromCache */);
@@ -1154,7 +1212,8 @@
                 null, /* textStrings */
                 null, /* textEntries */
                 1234, /* interfaceIndex */
-                network);
+                network,
+                Instant.MAX /* expirationTime */);
         // Verify onServiceNameRemoved callback
         listener.onServiceNameRemoved(removedInfo);
         verify(discListener, timeout(TIMEOUT_MS)).onServiceLost(argThat(info ->
@@ -1276,7 +1335,8 @@
                 List.of(MdnsServiceInfo.TextEntry.fromBytes(new byte[]{
                         'k', 'e', 'y', '=', (byte) 0xFF, (byte) 0xFE})) /* textEntries */,
                 1234,
-                network);
+                network,
+                Instant.ofEpochSecond(1000_000L) /* expirationTime */);
 
         // Verify onServiceFound callback
         doReturn(TEST_TIME_MS + 10L).when(mClock).elapsedRealtime();
@@ -1301,6 +1361,7 @@
         assertTrue(info.getHostAddresses().stream().anyMatch(
                 address -> address.equals(parseNumericAddress("2001:db8::2"))));
         assertEquals(network, info.getNetwork());
+        assertEquals(Instant.ofEpochSecond(1000_000L), info.getExpirationTime());
 
         // Verify the listener has been unregistered.
         verify(mDiscoveryManager, timeout(TIMEOUT_MS))
@@ -1518,6 +1579,82 @@
     }
 
     @Test
+    public void testAdvertiseCustomTtl_validTtl_success() {
+        runValidTtlAdvertisingTest(30L);
+        runValidTtlAdvertisingTest(10 * 3600L);
+    }
+
+    @Test
+    public void testAdvertiseCustomTtl_ttlSmallerThan30SecondsButClientIsSystemServer_success() {
+        when(mDeps.getCallingUid()).thenReturn(Process.SYSTEM_UID);
+
+        runValidTtlAdvertisingTest(29L);
+    }
+
+    @Test
+    public void testAdvertiseCustomTtl_ttlLargerThan10HoursButClientIsSystemServer_success() {
+        when(mDeps.getCallingUid()).thenReturn(Process.SYSTEM_UID);
+
+        runValidTtlAdvertisingTest(10 * 3600L + 1);
+        runValidTtlAdvertisingTest(0xffffffffL);
+    }
+
+    private void runValidTtlAdvertisingTest(long validTtlSeconds) {
+        setMdnsAdvertiserEnabled();
+
+        final NsdManager client = connectClient(mService);
+        final RegistrationListener regListener = mock(RegistrationListener.class);
+        final ArgumentCaptor<MdnsAdvertiser.AdvertiserCallback> cbCaptor =
+                ArgumentCaptor.forClass(MdnsAdvertiser.AdvertiserCallback.class);
+        verify(mDeps).makeMdnsAdvertiser(any(), any(), cbCaptor.capture(), any(), any(), any());
+
+        final NsdServiceInfo regInfo = new NsdServiceInfo("Service custom TTL", SERVICE_TYPE);
+        regInfo.setPort(1234);
+        final AdvertisingRequest request =
+                new AdvertisingRequest.Builder(regInfo, NsdManager.PROTOCOL_DNS_SD)
+                    .setTtl(Duration.ofSeconds(validTtlSeconds)).build();
+
+        client.registerService(request, Runnable::run, regListener);
+        waitForIdle();
+
+        final ArgumentCaptor<Integer> idCaptor = ArgumentCaptor.forClass(Integer.class);
+        final MdnsAdvertisingOptions expectedAdverstingOptions =
+                MdnsAdvertisingOptions.newBuilder().setTtl(request.getTtl()).build();
+        verify(mAdvertiser).addOrUpdateService(idCaptor.capture(), any(),
+                eq(expectedAdverstingOptions), anyInt());
+
+        // Verify onServiceRegistered callback
+        final MdnsAdvertiser.AdvertiserCallback cb = cbCaptor.getValue();
+        final int regId = idCaptor.getValue();
+        cb.onRegisterServiceSucceeded(regId, regInfo);
+
+        verify(regListener, timeout(TIMEOUT_MS)).onServiceRegistered(
+                argThat(info -> matches(info, new NsdServiceInfo(regInfo.getServiceName(), null))));
+    }
+
+    @Test
+    public void testAdvertiseCustomTtl_invalidTtl_FailsWithBadParameters() {
+        setMdnsAdvertiserEnabled();
+        final long invalidTtlSeconds = 29L;
+        final NsdManager client = connectClient(mService);
+        final RegistrationListener regListener = mock(RegistrationListener.class);
+        final ArgumentCaptor<MdnsAdvertiser.AdvertiserCallback> cbCaptor =
+                ArgumentCaptor.forClass(MdnsAdvertiser.AdvertiserCallback.class);
+        verify(mDeps).makeMdnsAdvertiser(any(), any(), cbCaptor.capture(), any(), any(), any());
+
+        final NsdServiceInfo regInfo = new NsdServiceInfo("Service custom TTL", SERVICE_TYPE);
+        regInfo.setPort(1234);
+        final AdvertisingRequest request =
+                new AdvertisingRequest.Builder(regInfo, NsdManager.PROTOCOL_DNS_SD)
+                    .setTtl(Duration.ofSeconds(invalidTtlSeconds)).build();
+        client.registerService(request, Runnable::run, regListener);
+        waitForIdle();
+
+        verify(regListener, timeout(TIMEOUT_MS))
+                .onRegistrationFailed(any(), eq(FAILURE_BAD_PARAMETERS));
+    }
+
+    @Test
     public void testStopServiceResolutionWithMdnsDiscoveryManager() {
         setMdnsDiscoveryManagerEnabled();
 
diff --git a/tests/unit/java/com/android/server/VpnManagerServiceTest.java b/tests/unit/java/com/android/server/VpnManagerServiceTest.java
deleted file mode 100644
index bf23cd1..0000000
--- a/tests/unit/java/com/android/server/VpnManagerServiceTest.java
+++ /dev/null
@@ -1,409 +0,0 @@
-/*
- * Copyright (C) 2022 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.android.server;
-
-import static android.os.Build.VERSION_CODES.R;
-
-import static com.android.testutils.ContextUtils.mockService;
-import static com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo;
-import static com.android.testutils.MiscAsserts.assertThrows;
-
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertFalse;
-import static org.junit.Assert.assertNotNull;
-import static org.junit.Assert.assertNull;
-import static org.junit.Assert.assertTrue;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.eq;
-import static org.mockito.Mockito.doReturn;
-import static org.mockito.Mockito.never;
-import static org.mockito.Mockito.times;
-import static org.mockito.Mockito.verify;
-
-import android.annotation.UserIdInt;
-import android.content.BroadcastReceiver;
-import android.content.Context;
-import android.content.Intent;
-import android.content.pm.PackageManager;
-import android.net.ConnectivityManager;
-import android.net.INetd;
-import android.net.Uri;
-import android.os.Handler;
-import android.os.HandlerThread;
-import android.os.INetworkManagementService;
-import android.os.Looper;
-import android.os.UserHandle;
-import android.os.UserManager;
-import android.security.Credentials;
-
-import androidx.test.filters.SmallTest;
-
-import com.android.internal.net.VpnProfile;
-import com.android.server.connectivity.Vpn;
-import com.android.server.connectivity.VpnProfileStore;
-import com.android.server.net.LockdownVpnTracker;
-import com.android.testutils.DevSdkIgnoreRule;
-import com.android.testutils.DevSdkIgnoreRunner;
-import com.android.testutils.HandlerUtils;
-
-import org.junit.Before;
-import org.junit.Rule;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-import org.mockito.ArgumentCaptor;
-import org.mockito.Mock;
-import org.mockito.MockitoAnnotations;
-
-import java.nio.charset.StandardCharsets;
-import java.util.List;
-
-@RunWith(DevSdkIgnoreRunner.class)
-@IgnoreUpTo(R) // VpnManagerService is not available before R
-@SmallTest
-public class VpnManagerServiceTest extends VpnTestBase {
-    private static final String CONTEXT_ATTRIBUTION_TAG = "VPN_MANAGER";
-
-    @Rule
-    public final DevSdkIgnoreRule mIgnoreRule = new DevSdkIgnoreRule();
-
-    private static final int TIMEOUT_MS = 2_000;
-
-    @Mock Context mContext;
-    @Mock Context mContextWithoutAttributionTag;
-    @Mock Context mSystemContext;
-    @Mock Context mUserAllContext;
-    private HandlerThread mHandlerThread;
-    @Mock private Vpn mVpn;
-    @Mock private INetworkManagementService mNms;
-    @Mock private ConnectivityManager mCm;
-    @Mock private UserManager mUserManager;
-    @Mock private INetd mNetd;
-    @Mock private PackageManager mPackageManager;
-    @Mock private VpnProfileStore mVpnProfileStore;
-    @Mock private LockdownVpnTracker mLockdownVpnTracker;
-
-    private VpnManagerServiceDependencies mDeps;
-    private VpnManagerService mService;
-    private BroadcastReceiver mUserPresentReceiver;
-    private BroadcastReceiver mIntentReceiver;
-    private final String mNotMyVpnPkg = "com.not.my.vpn";
-
-    class VpnManagerServiceDependencies extends VpnManagerService.Dependencies {
-        @Override
-        public HandlerThread makeHandlerThread() {
-            return mHandlerThread;
-        }
-
-        @Override
-        public INetworkManagementService getINetworkManagementService() {
-            return mNms;
-        }
-
-        @Override
-        public INetd getNetd() {
-            return mNetd;
-        }
-
-        @Override
-        public Vpn createVpn(Looper looper, Context context, INetworkManagementService nms,
-                INetd netd, @UserIdInt int userId) {
-            return mVpn;
-        }
-
-        @Override
-        public VpnProfileStore getVpnProfileStore() {
-            return mVpnProfileStore;
-        }
-
-        @Override
-        public LockdownVpnTracker createLockDownVpnTracker(Context context, Handler handler,
-                Vpn vpn, VpnProfile profile) {
-            return mLockdownVpnTracker;
-        }
-
-        @Override
-        public @UserIdInt int getMainUserId() {
-            return UserHandle.USER_SYSTEM;
-        }
-    }
-
-    @Before
-    public void setUp() throws Exception {
-        MockitoAnnotations.initMocks(this);
-
-        mHandlerThread = new HandlerThread("TestVpnManagerService");
-        mDeps = new VpnManagerServiceDependencies();
-
-        // The attribution tag is a dependency for IKE library to collect VPN metrics correctly
-        // and thus should not be changed without updating the IKE code.
-        doReturn(mContext)
-                .when(mContextWithoutAttributionTag)
-                .createAttributionContext(CONTEXT_ATTRIBUTION_TAG);
-
-        doReturn(mUserAllContext).when(mContext).createContextAsUser(UserHandle.ALL, 0);
-        doReturn(mSystemContext).when(mContext).createContextAsUser(UserHandle.SYSTEM, 0);
-        doReturn(mPackageManager).when(mContext).getPackageManager();
-        setMockedPackages(mPackageManager, sPackages);
-
-        mockService(mContext, ConnectivityManager.class, Context.CONNECTIVITY_SERVICE, mCm);
-        mockService(mContext, UserManager.class, Context.USER_SERVICE, mUserManager);
-        doReturn(SYSTEM_USER).when(mUserManager).getUserInfo(eq(SYSTEM_USER_ID));
-
-        mService = new VpnManagerService(mContextWithoutAttributionTag, mDeps);
-        mService.systemReady();
-
-        final ArgumentCaptor<BroadcastReceiver> intentReceiverCaptor =
-                ArgumentCaptor.forClass(BroadcastReceiver.class);
-        final ArgumentCaptor<BroadcastReceiver> userPresentReceiverCaptor =
-                ArgumentCaptor.forClass(BroadcastReceiver.class);
-        verify(mSystemContext).registerReceiver(
-                userPresentReceiverCaptor.capture(), any(), any(), any());
-        verify(mUserAllContext, times(2)).registerReceiver(
-                intentReceiverCaptor.capture(), any(), any(), any());
-        mUserPresentReceiver = userPresentReceiverCaptor.getValue();
-        mIntentReceiver = intentReceiverCaptor.getValue();
-
-        // Add user to create vpn in mVpn
-        onUserStarted(SYSTEM_USER_ID);
-        assertNotNull(mService.mVpns.get(SYSTEM_USER_ID));
-    }
-
-    @Test
-    public void testUpdateAppExclusionList() {
-        // Start vpn
-        mService.startVpnProfile(TEST_VPN_PKG);
-        verify(mVpn).startVpnProfile(eq(TEST_VPN_PKG));
-
-        // Remove package due to package replaced.
-        onPackageRemoved(PKGS[0], PKG_UIDS[0], true /* isReplacing */);
-        verify(mVpn, never()).refreshPlatformVpnAppExclusionList();
-
-        // Add package due to package replaced.
-        onPackageAdded(PKGS[0], PKG_UIDS[0], true /* isReplacing */);
-        verify(mVpn, never()).refreshPlatformVpnAppExclusionList();
-
-        // Remove package
-        onPackageRemoved(PKGS[0], PKG_UIDS[0], false /* isReplacing */);
-        verify(mVpn).refreshPlatformVpnAppExclusionList();
-
-        // Add the package back
-        onPackageAdded(PKGS[0], PKG_UIDS[0], false /* isReplacing */);
-        verify(mVpn, times(2)).refreshPlatformVpnAppExclusionList();
-    }
-
-    @Test
-    public void testStartVpnProfileFromDiffPackage() {
-        assertThrows(
-                SecurityException.class, () -> mService.startVpnProfile(mNotMyVpnPkg));
-    }
-
-    @Test
-    public void testStopVpnProfileFromDiffPackage() {
-        assertThrows(SecurityException.class, () -> mService.stopVpnProfile(mNotMyVpnPkg));
-    }
-
-    @Test
-    public void testGetProvisionedVpnProfileStateFromDiffPackage() {
-        assertThrows(SecurityException.class, () ->
-                mService.getProvisionedVpnProfileState(mNotMyVpnPkg));
-    }
-
-    @Test
-    public void testGetProvisionedVpnProfileState() {
-        mService.getProvisionedVpnProfileState(TEST_VPN_PKG);
-        verify(mVpn).getProvisionedVpnProfileState(TEST_VPN_PKG);
-    }
-
-    private Intent buildIntent(String action, String packageName, int userId, int uid,
-            boolean isReplacing) {
-        final Intent intent = new Intent(action);
-        intent.putExtra(Intent.EXTRA_USER_HANDLE, userId);
-        intent.putExtra(Intent.EXTRA_UID, uid);
-        intent.putExtra(Intent.EXTRA_REPLACING, isReplacing);
-        if (packageName != null) {
-            intent.setData(Uri.fromParts("package" /* scheme */, packageName, null /* fragment */));
-        }
-
-        return intent;
-    }
-
-    private void sendIntent(Intent intent) {
-        sendIntent(mIntentReceiver, mContext, intent);
-    }
-
-    private void sendIntent(BroadcastReceiver receiver, Context context, Intent intent) {
-        final Handler h = mHandlerThread.getThreadHandler();
-
-        // Send in handler thread.
-        h.post(() -> receiver.onReceive(context, intent));
-        HandlerUtils.waitForIdle(mHandlerThread, TIMEOUT_MS);
-    }
-
-    private void onUserStarted(int userId) {
-        sendIntent(buildIntent(Intent.ACTION_USER_STARTED,
-                null /* packageName */, userId, -1 /* uid */, false /* isReplacing */));
-    }
-
-    private void onUserUnlocked(int userId) {
-        sendIntent(buildIntent(Intent.ACTION_USER_UNLOCKED,
-                null /* packageName */, userId, -1 /* uid */, false /* isReplacing */));
-    }
-
-    private void onUserStopped(int userId) {
-        sendIntent(buildIntent(Intent.ACTION_USER_STOPPED,
-                null /* packageName */, userId, -1 /* uid */, false /* isReplacing */));
-    }
-
-    private void onLockDownReset() {
-        sendIntent(buildIntent(LockdownVpnTracker.ACTION_LOCKDOWN_RESET, null /* packageName */,
-                UserHandle.USER_SYSTEM, -1 /* uid */, false /* isReplacing */));
-    }
-
-    private void onPackageAdded(String packageName, int userId, int uid, boolean isReplacing) {
-        sendIntent(buildIntent(Intent.ACTION_PACKAGE_ADDED, packageName, userId, uid, isReplacing));
-    }
-
-    private void onPackageAdded(String packageName, int uid, boolean isReplacing) {
-        onPackageAdded(packageName, UserHandle.USER_SYSTEM, uid, isReplacing);
-    }
-
-    private void onPackageRemoved(String packageName, int userId, int uid, boolean isReplacing) {
-        sendIntent(buildIntent(Intent.ACTION_PACKAGE_REMOVED, packageName, userId, uid,
-                isReplacing));
-    }
-
-    private void onPackageRemoved(String packageName, int uid, boolean isReplacing) {
-        onPackageRemoved(packageName, UserHandle.USER_SYSTEM, uid, isReplacing);
-    }
-
-    @Test
-    public void testReceiveIntentFromNonHandlerThread() {
-        assertThrows(IllegalStateException.class, () ->
-                mIntentReceiver.onReceive(mContext, buildIntent(Intent.ACTION_PACKAGE_REMOVED,
-                        PKGS[0], UserHandle.USER_SYSTEM, PKG_UIDS[0], true /* isReplacing */)));
-
-        assertThrows(IllegalStateException.class, () ->
-                mUserPresentReceiver.onReceive(mContext, new Intent(Intent.ACTION_USER_PRESENT)));
-    }
-
-    private void setupLockdownVpn(String packageName) {
-        final byte[] profileTag = packageName.getBytes(StandardCharsets.UTF_8);
-        doReturn(profileTag).when(mVpnProfileStore).get(Credentials.LOCKDOWN_VPN);
-    }
-
-    private void setupVpnProfile(String profileName) {
-        final VpnProfile profile = new VpnProfile(profileName);
-        profile.name = profileName;
-        profile.server = "192.0.2.1";
-        profile.dnsServers = "8.8.8.8";
-        profile.type = VpnProfile.TYPE_IPSEC_XAUTH_PSK;
-        final byte[] encodedProfile = profile.encode();
-        doReturn(encodedProfile).when(mVpnProfileStore).get(Credentials.VPN + profileName);
-    }
-
-    @Test
-    public void testUserPresent() {
-        // Verify that LockDownVpnTracker is not created.
-        verify(mLockdownVpnTracker, never()).init();
-
-        setupLockdownVpn(TEST_VPN_PKG);
-        setupVpnProfile(TEST_VPN_PKG);
-
-        // mUserPresentReceiver only registers ACTION_USER_PRESENT intent and does no verification
-        // on action, so an empty intent is enough.
-        sendIntent(mUserPresentReceiver, mSystemContext, new Intent());
-
-        verify(mLockdownVpnTracker).init();
-        verify(mSystemContext).unregisterReceiver(mUserPresentReceiver);
-        verify(mUserAllContext, never()).unregisterReceiver(any());
-    }
-
-    @Test
-    public void testUpdateLockdownVpn() {
-        setupLockdownVpn(TEST_VPN_PKG);
-        onUserUnlocked(SYSTEM_USER_ID);
-
-        // Will not create lockDownVpnTracker w/o valid profile configured in the keystore
-        verify(mLockdownVpnTracker, never()).init();
-
-        setupVpnProfile(TEST_VPN_PKG);
-
-        // Remove the user from mVpns
-        onUserStopped(SYSTEM_USER_ID);
-        onUserUnlocked(SYSTEM_USER_ID);
-        verify(mLockdownVpnTracker, never()).init();
-
-        // Add user back
-        onUserStarted(SYSTEM_USER_ID);
-        verify(mLockdownVpnTracker).init();
-
-        // Trigger another update. The existing LockDownVpnTracker should be shut down and
-        // initialize another one.
-        onUserUnlocked(SYSTEM_USER_ID);
-        verify(mLockdownVpnTracker).shutdown();
-        verify(mLockdownVpnTracker, times(2)).init();
-    }
-
-    @Test
-    public void testLockdownReset() {
-        // Init LockdownVpnTracker
-        setupLockdownVpn(TEST_VPN_PKG);
-        setupVpnProfile(TEST_VPN_PKG);
-        onUserUnlocked(SYSTEM_USER_ID);
-        verify(mLockdownVpnTracker).init();
-
-        onLockDownReset();
-        verify(mLockdownVpnTracker).reset();
-    }
-
-    @Test
-    public void testLockdownResetWhenLockdownVpnTrackerIsNotInit() {
-        setupLockdownVpn(TEST_VPN_PKG);
-        setupVpnProfile(TEST_VPN_PKG);
-
-        onLockDownReset();
-
-        // LockDownVpnTracker is not created. Lockdown reset will not take effect.
-        verify(mLockdownVpnTracker, never()).reset();
-    }
-
-    @Test
-    public void testIsVpnLockdownEnabled() {
-        // Vpn is created but the VPN lockdown is not enabled.
-        assertFalse(mService.isVpnLockdownEnabled(SYSTEM_USER_ID));
-
-        // Set lockdown for the SYSTEM_USER_ID VPN.
-        doReturn(true).when(mVpn).getLockdown();
-        assertTrue(mService.isVpnLockdownEnabled(SYSTEM_USER_ID));
-
-        // Even lockdown is enabled but no Vpn is created for SECONDARY_USER.
-        assertFalse(mService.isVpnLockdownEnabled(SECONDARY_USER.id));
-    }
-
-    @Test
-    public void testGetVpnLockdownAllowlist() {
-        doReturn(null).when(mVpn).getLockdownAllowlist();
-        assertNull(mService.getVpnLockdownAllowlist(SYSTEM_USER_ID));
-
-        final List<String> expected = List.of(PKGS);
-        doReturn(expected).when(mVpn).getLockdownAllowlist();
-        assertEquals(expected, mService.getVpnLockdownAllowlist(SYSTEM_USER_ID));
-
-        // Even lockdown is enabled but no Vpn is created for SECONDARY_USER.
-        assertNull(mService.getVpnLockdownAllowlist(SECONDARY_USER.id));
-    }
-}
diff --git a/tests/unit/java/com/android/server/VpnTestBase.java b/tests/unit/java/com/android/server/VpnTestBase.java
deleted file mode 100644
index 6113872..0000000
--- a/tests/unit/java/com/android/server/VpnTestBase.java
+++ /dev/null
@@ -1,97 +0,0 @@
-/*
- * Copyright (C) 2022 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.android.server;
-
-import static android.content.pm.UserInfo.FLAG_ADMIN;
-import static android.content.pm.UserInfo.FLAG_MANAGED_PROFILE;
-import static android.content.pm.UserInfo.FLAG_PRIMARY;
-import static android.content.pm.UserInfo.FLAG_RESTRICTED;
-
-import static org.mockito.ArgumentMatchers.anyInt;
-import static org.mockito.ArgumentMatchers.anyString;
-import static org.mockito.Mockito.doAnswer;
-
-import android.content.pm.PackageManager;
-import android.content.pm.UserInfo;
-import android.os.Process;
-import android.os.UserHandle;
-import android.util.ArrayMap;
-
-import java.util.Arrays;
-import java.util.List;
-import java.util.Map;
-import java.util.stream.Collectors;
-
-/** Common variables or methods shared between VpnTest and VpnManagerServiceTest. */
-public class VpnTestBase {
-    protected static final String TEST_VPN_PKG = "com.testvpn.vpn";
-    /**
-     * Names and UIDs for some fake packages. Important points:
-     *  - UID is ordered increasing.
-     *  - One pair of packages have consecutive UIDs.
-     */
-    protected static final String[] PKGS = {"com.example", "org.example", "net.example", "web.vpn"};
-    protected static final int[] PKG_UIDS = {10066, 10077, 10078, 10400};
-    // Mock packages
-    protected static final Map<String, Integer> sPackages = new ArrayMap<>();
-    static {
-        for (int i = 0; i < PKGS.length; i++) {
-            sPackages.put(PKGS[i], PKG_UIDS[i]);
-        }
-        sPackages.put(TEST_VPN_PKG, Process.myUid());
-    }
-
-    // Mock users
-    protected static final int SYSTEM_USER_ID = 0;
-    protected static final UserInfo SYSTEM_USER = new UserInfo(0, "system", UserInfo.FLAG_PRIMARY);
-    protected static final UserInfo PRIMARY_USER = new UserInfo(27, "Primary",
-            FLAG_ADMIN | FLAG_PRIMARY);
-    protected static final UserInfo SECONDARY_USER = new UserInfo(15, "Secondary", FLAG_ADMIN);
-    protected static final UserInfo RESTRICTED_PROFILE_A = new UserInfo(40, "RestrictedA",
-            FLAG_RESTRICTED);
-    protected static final UserInfo RESTRICTED_PROFILE_B = new UserInfo(42, "RestrictedB",
-            FLAG_RESTRICTED);
-    protected static final UserInfo MANAGED_PROFILE_A = new UserInfo(45, "ManagedA",
-            FLAG_MANAGED_PROFILE);
-    static {
-        RESTRICTED_PROFILE_A.restrictedProfileParentId = PRIMARY_USER.id;
-        RESTRICTED_PROFILE_B.restrictedProfileParentId = SECONDARY_USER.id;
-        MANAGED_PROFILE_A.profileGroupId = PRIMARY_USER.id;
-    }
-
-    // Populate a fake packageName-to-UID mapping.
-    protected void setMockedPackages(PackageManager mockPm, final Map<String, Integer> packages) {
-        try {
-            doAnswer(invocation -> {
-                final String appName = (String) invocation.getArguments()[0];
-                final int userId = (int) invocation.getArguments()[1];
-
-                final Integer appId = packages.get(appName);
-                if (appId == null) {
-                    throw new PackageManager.NameNotFoundException(appName);
-                }
-
-                return UserHandle.getUid(userId, appId);
-            }).when(mockPm).getPackageUidAsUser(anyString(), anyInt());
-        } catch (Exception e) {
-        }
-    }
-
-    protected List<Integer> toList(int[] arr) {
-        return Arrays.stream(arr).boxed().collect(Collectors.toList());
-    }
-}
diff --git a/tests/unit/java/com/android/server/connectivity/AutomaticOnOffKeepaliveTrackerTest.java b/tests/unit/java/com/android/server/connectivity/AutomaticOnOffKeepaliveTrackerTest.java
index 6cc301d..c53feee 100644
--- a/tests/unit/java/com/android/server/connectivity/AutomaticOnOffKeepaliveTrackerTest.java
+++ b/tests/unit/java/com/android/server/connectivity/AutomaticOnOffKeepaliveTrackerTest.java
@@ -20,10 +20,8 @@
 import static android.net.ConnectivityManager.TYPE_MOBILE;
 import static android.net.NetworkAgent.CMD_STOP_SOCKET_KEEPALIVE;
 import static android.net.NetworkCapabilities.TRANSPORT_CELLULAR;
-
 import static com.android.server.connectivity.AutomaticOnOffKeepaliveTracker.METRICS_COLLECTION_DURATION_MS;
 import static com.android.testutils.HandlerUtils.visibleOnHandlerThread;
-
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNotNull;
@@ -71,29 +69,16 @@
 import android.os.Message;
 import android.os.SystemClock;
 import android.telephony.SubscriptionManager;
-import android.test.suitebuilder.annotation.SmallTest;
 import android.util.Log;
-
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
-
+import androidx.test.filters.SmallTest;
 import com.android.internal.util.IndentingPrintWriter;
 import com.android.server.connectivity.AutomaticOnOffKeepaliveTracker.AutomaticOnOffKeepalive;
 import com.android.server.connectivity.KeepaliveTracker.KeepaliveInfo;
 import com.android.testutils.DevSdkIgnoreRule;
 import com.android.testutils.DevSdkIgnoreRunner;
 import com.android.testutils.HandlerUtils;
-
-import libcore.util.HexEncoding;
-
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-import org.mockito.ArgumentCaptor;
-import org.mockito.Mock;
-import org.mockito.MockitoAnnotations;
-
 import java.io.FileDescriptor;
 import java.io.StringWriter;
 import java.net.Inet4Address;
@@ -103,6 +88,14 @@
 import java.nio.ByteOrder;
 import java.util.ArrayList;
 import java.util.List;
+import libcore.util.HexEncoding;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
 
 @RunWith(DevSdkIgnoreRunner.class)
 @SmallTest
diff --git a/tests/unit/java/com/android/server/connectivity/CarrierPrivilegeAuthenticatorTest.java b/tests/unit/java/com/android/server/connectivity/CarrierPrivilegeAuthenticatorTest.java
index 7bd2b56..ab81abc 100644
--- a/tests/unit/java/com/android/server/connectivity/CarrierPrivilegeAuthenticatorTest.java
+++ b/tests/unit/java/com/android/server/connectivity/CarrierPrivilegeAuthenticatorTest.java
@@ -21,6 +21,7 @@
 import static android.telephony.TelephonyManager.ACTION_MULTI_SIM_CONFIG_CHANGED;
 
 import static com.android.server.connectivity.ConnectivityFlags.CARRIER_SERVICE_CHANGED_USE_CALLBACK;
+import static com.android.testutils.HandlerUtils.visibleOnHandlerThread;
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
@@ -45,6 +46,7 @@
 import android.net.NetworkCapabilities;
 import android.net.TelephonyNetworkSpecifier;
 import android.os.Build;
+import android.os.Handler;
 import android.os.HandlerThread;
 import android.telephony.TelephonyManager;
 
@@ -56,6 +58,7 @@
 import com.android.testutils.DevSdkIgnoreRule;
 import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo;
 import com.android.testutils.DevSdkIgnoreRunner;
+import com.android.testutils.HandlerUtils;
 
 import org.junit.After;
 import org.junit.Rule;
@@ -85,6 +88,7 @@
 
     private static final int SUBSCRIPTION_COUNT = 2;
     private static final int TEST_SUBSCRIPTION_ID = 1;
+    private static final int TIMEOUT_MS = 1_000;
 
     @NonNull private final Context mContext;
     @NonNull private final TelephonyManager mTelephonyManager;
@@ -97,13 +101,16 @@
     private final String mTestPkg = "com.android.server.connectivity.test";
     private final BroadcastReceiver mMultiSimBroadcastReceiver;
     @NonNull private final HandlerThread mHandlerThread;
+    @NonNull private final Handler mCsHandler;
+    @NonNull private final HandlerThread mCsHandlerThread;
 
     public class TestCarrierPrivilegeAuthenticator extends CarrierPrivilegeAuthenticator {
         TestCarrierPrivilegeAuthenticator(@NonNull final Context c,
                 @NonNull final Dependencies deps,
-                @NonNull final TelephonyManager t) {
+                @NonNull final TelephonyManager t,
+                @NonNull final Handler handler) {
             super(c, deps, t, mTelephonyManagerShim, true /* requestRestrictedWifiEnabled */,
-                    mListener);
+                    mListener, handler);
         }
         @Override
         protected int getSubId(int slotIndex) {
@@ -112,8 +119,11 @@
     }
 
     @After
-    public void tearDown() {
+    public void tearDown() throws Exception {
         mHandlerThread.quit();
+        mHandlerThread.join();
+        mCsHandlerThread.quit();
+        mCsHandlerThread.join();
     }
 
     /** Parameters to test both using callbacks or the old broadcast */
@@ -141,8 +151,14 @@
         final ApplicationInfo applicationInfo = new ApplicationInfo();
         applicationInfo.uid = mCarrierConfigPkgUid;
         doReturn(applicationInfo).when(mPackageManager).getApplicationInfo(eq(mTestPkg), anyInt());
-        mCarrierPrivilegeAuthenticator =
-                new TestCarrierPrivilegeAuthenticator(mContext, deps, mTelephonyManager);
+        mCsHandlerThread = new HandlerThread(
+                CarrierPrivilegeAuthenticatorTest.class.getSimpleName() + "-CsHandlerThread");
+        mCsHandlerThread.start();
+        mCsHandler = new Handler(mCsHandlerThread.getLooper());
+        mCarrierPrivilegeAuthenticator = new TestCarrierPrivilegeAuthenticator(mContext, deps,
+                mTelephonyManager, mCsHandler);
+        mCarrierPrivilegeAuthenticator.start();
+        HandlerUtils.waitForIdle(mCsHandlerThread, TIMEOUT_MS);
         final ArgumentCaptor<BroadcastReceiver> receiverCaptor =
                 ArgumentCaptor.forClass(BroadcastReceiver.class);
         verify(mContext).registerReceiver(receiverCaptor.capture(), argThat(filter ->
@@ -178,7 +194,9 @@
         assertNotNull(initialListeners.get(1));
         assertEquals(2, initialListeners.size());
 
-        initialListeners.get(0).onCarrierServiceChanged(null, mCarrierConfigPkgUid);
+        visibleOnHandlerThread(mCsHandler, () -> {
+            initialListeners.get(0).onCarrierServiceChanged(null, mCarrierConfigPkgUid);
+        });
 
         final NetworkCapabilities.Builder ncBuilder = new NetworkCapabilities.Builder()
                 .addTransportType(TRANSPORT_CELLULAR)
@@ -201,10 +219,10 @@
 
         doReturn(1).when(mTelephonyManager).getActiveModemCount();
 
-        // This is a little bit cavalier in that the call to onReceive is not on the handler
-        // thread that was specified in registerReceiver.
-        // TODO : capture the handler and call this on it if this causes flakiness.
-        mMultiSimBroadcastReceiver.onReceive(mContext, buildTestMultiSimConfigBroadcastIntent());
+        visibleOnHandlerThread(mCsHandler, () -> {
+            mMultiSimBroadcastReceiver.onReceive(mContext,
+                    buildTestMultiSimConfigBroadcastIntent());
+        });
         // Check all listeners have been removed
         for (CarrierPrivilegesListenerShim listener : initialListeners.values()) {
             verify(mTelephonyManagerShim).removeCarrierPrivilegesListener(eq(listener));
@@ -216,7 +234,9 @@
         assertNotNull(newListeners.get(0));
         assertEquals(1, newListeners.size());
 
-        newListeners.get(0).onCarrierServiceChanged(null, mCarrierConfigPkgUid);
+        visibleOnHandlerThread(mCsHandler, () -> {
+            newListeners.get(0).onCarrierServiceChanged(null, mCarrierConfigPkgUid);
+        });
 
         final TelephonyNetworkSpecifier specifier =
                 new TelephonyNetworkSpecifier(TEST_SUBSCRIPTION_ID);
@@ -235,12 +255,17 @@
     public void testCarrierPrivilegesLostDueToCarrierServiceUpdate() throws Exception {
         final CarrierPrivilegesListenerShim l = getCarrierPrivilegesListeners().get(0);
 
-        l.onCarrierServiceChanged(null, mCarrierConfigPkgUid);
-        l.onCarrierServiceChanged(null, mCarrierConfigPkgUid + 1);
+        visibleOnHandlerThread(mCsHandler, () -> {
+            l.onCarrierServiceChanged(null, mCarrierConfigPkgUid);
+            l.onCarrierServiceChanged(null, mCarrierConfigPkgUid + 1);
+        });
         if (mUseCallbacks) {
             verify(mListener).accept(eq(mCarrierConfigPkgUid), eq(TEST_SUBSCRIPTION_ID));
         }
-        l.onCarrierServiceChanged(null, mCarrierConfigPkgUid + 2);
+
+        visibleOnHandlerThread(mCsHandler, () -> {
+            l.onCarrierServiceChanged(null, mCarrierConfigPkgUid + 2);
+        });
         if (mUseCallbacks) {
             verify(mListener).accept(eq(mCarrierConfigPkgUid + 1), eq(TEST_SUBSCRIPTION_ID));
         }
@@ -260,8 +285,10 @@
         final ApplicationInfo applicationInfo = new ApplicationInfo();
         applicationInfo.uid = mCarrierConfigPkgUid + 1;
         doReturn(applicationInfo).when(mPackageManager).getApplicationInfo(eq(mTestPkg), anyInt());
-        listener.onCarrierPrivilegesChanged(Collections.emptyList(), new int[] {});
-        listener.onCarrierServiceChanged(null, applicationInfo.uid);
+        visibleOnHandlerThread(mCsHandler, () -> {
+            listener.onCarrierPrivilegesChanged(Collections.emptyList(), new int[]{});
+            listener.onCarrierServiceChanged(null, applicationInfo.uid);
+        });
 
         assertFalse(mCarrierPrivilegeAuthenticator.isCarrierServiceUidForNetworkCapabilities(
                 mCarrierConfigPkgUid, nc));
@@ -272,7 +299,9 @@
     @Test
     public void testDefaultSubscription() throws Exception {
         final CarrierPrivilegesListenerShim listener = getCarrierPrivilegesListeners().get(0);
-        listener.onCarrierServiceChanged(null, mCarrierConfigPkgUid);
+        visibleOnHandlerThread(mCsHandler, () -> {
+            listener.onCarrierServiceChanged(null, mCarrierConfigPkgUid);
+        });
 
         final NetworkCapabilities.Builder ncBuilder = new NetworkCapabilities.Builder();
         ncBuilder.addTransportType(TRANSPORT_CELLULAR);
@@ -297,7 +326,9 @@
     @IgnoreUpTo(Build.VERSION_CODES.TIRAMISU)
     public void testNetworkCapabilitiesContainOneSubId() throws Exception {
         final CarrierPrivilegesListenerShim listener = getCarrierPrivilegesListeners().get(0);
-        listener.onCarrierServiceChanged(null, mCarrierConfigPkgUid);
+        visibleOnHandlerThread(mCsHandler, () -> {
+            listener.onCarrierServiceChanged(null, mCarrierConfigPkgUid);
+        });
 
         final NetworkCapabilities.Builder ncBuilder = new NetworkCapabilities.Builder();
         ncBuilder.addTransportType(TRANSPORT_WIFI);
@@ -311,7 +342,9 @@
     @IgnoreUpTo(Build.VERSION_CODES.TIRAMISU)
     public void testNetworkCapabilitiesContainTwoSubIds() throws Exception {
         final CarrierPrivilegesListenerShim listener = getCarrierPrivilegesListeners().get(0);
-        listener.onCarrierServiceChanged(null, mCarrierConfigPkgUid);
+        visibleOnHandlerThread(mCsHandler, () -> {
+            listener.onCarrierServiceChanged(null, mCarrierConfigPkgUid);
+        });
 
         final NetworkCapabilities.Builder ncBuilder = new NetworkCapabilities.Builder();
         ncBuilder.addTransportType(TRANSPORT_WIFI);
diff --git a/tests/unit/java/com/android/server/connectivity/ClatCoordinatorTest.java b/tests/unit/java/com/android/server/connectivity/ClatCoordinatorTest.java
index 88044be..da7fda3 100644
--- a/tests/unit/java/com/android/server/connectivity/ClatCoordinatorTest.java
+++ b/tests/unit/java/com/android/server/connectivity/ClatCoordinatorTest.java
@@ -526,13 +526,13 @@
                     + "v4: /192.0.0.46, v6: /2001:db8:0:b11::464, pfx96: /64:ff9b::, "
                     + "pid: 10483, cookie: 27149", dumpStrings[0].trim());
             assertEquals("Forwarding rules:", dumpStrings[1].trim());
-            assertEquals("BPF ingress map: iif nat64Prefix v6Addr -> v4Addr oif",
+            assertEquals("BPF ingress map: iif nat64Prefix v6Addr -> v4Addr oif (packets bytes)",
                     dumpStrings[2].trim());
-            assertEquals("1000 /64:ff9b::/96 /2001:db8:0:b11::464 -> /192.0.0.46 1001",
+            assertEquals("1000 /64:ff9b::/96 /2001:db8:0:b11::464 -> /192.0.0.46 1001 (0 0)",
                     dumpStrings[3].trim());
-            assertEquals("BPF egress map: iif v4Addr -> v6Addr nat64Prefix oif",
+            assertEquals("BPF egress map: iif v4Addr -> v6Addr nat64Prefix oif (packets bytes)",
                     dumpStrings[4].trim());
-            assertEquals("1001 /192.0.0.46 -> /2001:db8:0:b11::464 /64:ff9b::/96 1000 ether",
+            assertEquals("1001 /192.0.0.46 -> /2001:db8:0:b11::464 /64:ff9b::/96 1000 ether (0 0)",
                     dumpStrings[5].trim());
         } else {
             assertEquals(1, dumpStrings.length);
diff --git a/tests/unit/java/com/android/server/connectivity/IpConnectivityEventBuilderTest.java b/tests/unit/java/com/android/server/connectivity/IpConnectivityEventBuilderTest.java
index 52b05aa..ab1e467 100644
--- a/tests/unit/java/com/android/server/connectivity/IpConnectivityEventBuilderTest.java
+++ b/tests/unit/java/com/android/server/connectivity/IpConnectivityEventBuilderTest.java
@@ -26,7 +26,6 @@
 import static com.android.server.connectivity.metrics.nano.IpConnectivityLogClass.IpConnectivityLog;
 import static com.android.server.connectivity.metrics.nano.IpConnectivityLogClass.MULTIPLE;
 import static com.android.server.connectivity.metrics.nano.IpConnectivityLogClass.WIFI;
-
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.fail;
 
@@ -43,17 +42,14 @@
 import android.net.metrics.ValidationProbeEvent;
 import android.net.metrics.WakeupStats;
 import android.os.Build;
-import android.test.suitebuilder.annotation.SmallTest;
-
+import androidx.test.filters.SmallTest;
 import com.android.server.connectivity.metrics.nano.IpConnectivityLogClass.IpConnectivityEvent;
 import com.android.testutils.DevSdkIgnoreRule;
 import com.android.testutils.DevSdkIgnoreRunner;
-
-import org.junit.Test;
-import org.junit.runner.RunWith;
-
 import java.util.Arrays;
 import java.util.List;
+import org.junit.Test;
+import org.junit.runner.RunWith;
 
 // TODO: instead of comparing textpb to textpb, parse textpb and compare proto to proto.
 @RunWith(DevSdkIgnoreRunner.class)
diff --git a/tests/unit/java/com/android/server/connectivity/IpConnectivityMetricsTest.java b/tests/unit/java/com/android/server/connectivity/IpConnectivityMetricsTest.java
index 5881a8e..91626d2 100644
--- a/tests/unit/java/com/android/server/connectivity/IpConnectivityMetricsTest.java
+++ b/tests/unit/java/com/android/server/connectivity/IpConnectivityMetricsTest.java
@@ -18,7 +18,6 @@
 
 import static android.net.metrics.INetdEventListener.EVENT_GETADDRINFO;
 import static android.net.metrics.INetdEventListener.EVENT_GETHOSTBYNAME;
-
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.fail;
@@ -50,14 +49,14 @@
 import android.os.Parcelable;
 import android.os.SystemClock;
 import android.system.OsConstants;
-import android.test.suitebuilder.annotation.SmallTest;
 import android.util.Base64;
-
+import androidx.test.filters.SmallTest;
 import com.android.internal.util.BitUtils;
 import com.android.server.connectivity.metrics.nano.IpConnectivityLogClass;
 import com.android.testutils.DevSdkIgnoreRule;
 import com.android.testutils.DevSdkIgnoreRunner;
-
+import java.io.PrintWriter;
+import java.io.StringWriter;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -65,9 +64,6 @@
 import org.mockito.Mock;
 import org.mockito.MockitoAnnotations;
 
-import java.io.PrintWriter;
-import java.io.StringWriter;
-
 @RunWith(DevSdkIgnoreRunner.class)
 @SmallTest
 @DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.R)
diff --git a/tests/unit/java/com/android/server/connectivity/NetdEventListenerServiceTest.java b/tests/unit/java/com/android/server/connectivity/NetdEventListenerServiceTest.java
index d667662..89e2a51 100644
--- a/tests/unit/java/com/android/server/connectivity/NetdEventListenerServiceTest.java
+++ b/tests/unit/java/com/android/server/connectivity/NetdEventListenerServiceTest.java
@@ -18,9 +18,7 @@
 
 import static android.net.metrics.INetdEventListener.EVENT_GETADDRINFO;
 import static android.net.metrics.INetdEventListener.EVENT_GETHOSTBYNAME;
-
 import static com.android.testutils.MiscAsserts.assertStringContains;
-
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.fail;
 import static org.mockito.ArgumentMatchers.any;
@@ -34,27 +32,23 @@
 import android.net.NetworkCapabilities;
 import android.os.Build;
 import android.system.OsConstants;
-import android.test.suitebuilder.annotation.SmallTest;
 import android.util.Base64;
-
+import androidx.test.filters.SmallTest;
 import com.android.server.connectivity.metrics.nano.IpConnectivityLogClass.IpConnectivityEvent;
 import com.android.server.connectivity.metrics.nano.IpConnectivityLogClass.IpConnectivityLog;
 import com.android.testutils.DevSdkIgnoreRule;
 import com.android.testutils.DevSdkIgnoreRunner;
-
-import libcore.util.EmptyArray;
-
-import org.junit.Before;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-import org.mockito.ArgumentCaptor;
-
 import java.io.FileOutputStream;
 import java.io.PrintWriter;
 import java.io.StringWriter;
 import java.util.Arrays;
 import java.util.Comparator;
 import java.util.List;
+import libcore.util.EmptyArray;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
 
 @RunWith(DevSdkIgnoreRunner.class)
 @SmallTest
diff --git a/tests/unit/java/com/android/server/connectivity/SatelliteAccessControllerTest.kt b/tests/unit/java/com/android/server/connectivity/SatelliteAccessControllerTest.kt
index 193078b..7885325 100644
--- a/tests/unit/java/com/android/server/connectivity/SatelliteAccessControllerTest.kt
+++ b/tests/unit/java/com/android/server/connectivity/SatelliteAccessControllerTest.kt
@@ -18,17 +18,22 @@
 import android.Manifest
 import android.app.role.OnRoleHoldersChangedListener
 import android.app.role.RoleManager
+import android.content.BroadcastReceiver
 import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
 import android.content.pm.ApplicationInfo
 import android.content.pm.PackageManager
-import android.content.pm.UserInfo
 import android.os.Build
 import android.os.Handler
+import android.os.Looper
 import android.os.UserHandle
+import android.os.UserManager
 import android.util.ArraySet
-import com.android.server.makeMockUserManager
 import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo
 import com.android.testutils.DevSdkIgnoreRunner
+import java.util.concurrent.Executor
+import java.util.function.Consumer
 import org.junit.Before
 import org.junit.Test
 import org.junit.runner.RunWith
@@ -36,30 +41,32 @@
 import org.mockito.ArgumentMatchers.any
 import org.mockito.ArgumentMatchers.anyInt
 import org.mockito.ArgumentMatchers.eq
+import org.mockito.ArgumentMatchers.isNull
 import org.mockito.Mockito.doReturn
 import org.mockito.Mockito.mock
 import org.mockito.Mockito.never
+import org.mockito.Mockito.timeout
+import org.mockito.Mockito.times
 import org.mockito.Mockito.verify
-import java.util.concurrent.Executor
-import java.util.function.Consumer
 
-private const val USER = 0
-val USER_INFO = UserInfo(USER, "" /* name */, UserInfo.FLAG_PRIMARY)
-val USER_HANDLE = UserHandle(USER)
 private const val PRIMARY_USER = 0
 private const val SECONDARY_USER = 10
 private val PRIMARY_USER_HANDLE = UserHandle.of(PRIMARY_USER)
 private val SECONDARY_USER_HANDLE = UserHandle.of(SECONDARY_USER)
+
 // sms app names
 private const val SMS_APP1 = "sms_app_1"
 private const val SMS_APP2 = "sms_app_2"
+
 // sms app ids
 private const val SMS_APP_ID1 = 100
 private const val SMS_APP_ID2 = 101
+
 // UID for app1 and app2 on primary user
 // These app could become default sms app for user1
 private val PRIMARY_USER_SMS_APP_UID1 = UserHandle.getUid(PRIMARY_USER, SMS_APP_ID1)
 private val PRIMARY_USER_SMS_APP_UID2 = UserHandle.getUid(PRIMARY_USER, SMS_APP_ID2)
+
 // UID for app1 and app2 on secondary user
 // These app could become default sms app for user2
 private val SECONDARY_USER_SMS_APP_UID1 = UserHandle.getUid(SECONDARY_USER, SMS_APP_ID1)
@@ -69,154 +76,259 @@
 @IgnoreUpTo(Build.VERSION_CODES.TIRAMISU)
 class SatelliteAccessControllerTest {
     private val context = mock(Context::class.java)
-    private val mPackageManager = mock(PackageManager::class.java)
-    private val mHandler = mock(Handler::class.java)
-    private val mRoleManager =
-        mock(SatelliteAccessController.Dependencies::class.java)
+    private val primaryUserContext = mock(Context::class.java)
+    private val secondaryUserContext = mock(Context::class.java)
+    private val mPackageManagerPrimaryUser = mock(PackageManager::class.java)
+    private val mPackageManagerSecondaryUser = mock(PackageManager::class.java)
+    private val mDeps = mock(SatelliteAccessController.Dependencies::class.java)
     private val mCallback = mock(Consumer::class.java) as Consumer<Set<Int>>
-    private val mSatelliteAccessController =
-        SatelliteAccessController(context, mRoleManager, mCallback, mHandler)
+    private val userManager = mock(UserManager::class.java)
+    private val mHandler = Handler(Looper.getMainLooper())
+    private var mSatelliteAccessController =
+        SatelliteAccessController(context, mDeps, mCallback, mHandler)
     private lateinit var mRoleHolderChangedListener: OnRoleHoldersChangedListener
+    private lateinit var mUserRemovedReceiver: BroadcastReceiver
+
+    private fun <T> mockService(name: String, clazz: Class<T>, service: T) {
+        doReturn(name).`when`(context).getSystemServiceName(clazz)
+        doReturn(service).`when`(context).getSystemService(name)
+        if (context.getSystemService(clazz) == null) {
+            // Test is using mockito-extended
+            doReturn(service).`when`(context).getSystemService(clazz)
+        }
+    }
+
     @Before
     @Throws(PackageManager.NameNotFoundException::class)
     fun setup() {
-        makeMockUserManager(USER_INFO, USER_HANDLE)
-        doReturn(context).`when`(context).createContextAsUser(any(), anyInt())
-        doReturn(mPackageManager).`when`(context).packageManager
+        doReturn(emptyList<UserHandle>()).`when`(userManager).getUserHandles(true)
+        mockService(Context.USER_SERVICE, UserManager::class.java, userManager)
 
-        doReturn(PackageManager.PERMISSION_GRANTED)
-            .`when`(mPackageManager)
-            .checkPermission(Manifest.permission.SATELLITE_COMMUNICATION, SMS_APP1)
-        doReturn(PackageManager.PERMISSION_GRANTED)
-            .`when`(mPackageManager)
-            .checkPermission(Manifest.permission.SATELLITE_COMMUNICATION, SMS_APP2)
+        doReturn(primaryUserContext).`when`(context).createContextAsUser(PRIMARY_USER_HANDLE, 0)
+        doReturn(mPackageManagerPrimaryUser).`when`(primaryUserContext).packageManager
 
-        // Initialise default message application primary user package1
+        doReturn(secondaryUserContext).`when`(context).createContextAsUser(SECONDARY_USER_HANDLE, 0)
+        doReturn(mPackageManagerSecondaryUser).`when`(secondaryUserContext).packageManager
+
+        for (app in listOf(SMS_APP1, SMS_APP2)) {
+            doReturn(PackageManager.PERMISSION_GRANTED)
+                .`when`(mPackageManagerPrimaryUser)
+                .checkPermission(Manifest.permission.SATELLITE_COMMUNICATION, app)
+            doReturn(PackageManager.PERMISSION_GRANTED)
+                .`when`(mPackageManagerSecondaryUser)
+                .checkPermission(Manifest.permission.SATELLITE_COMMUNICATION, app)
+        }
+
+        // Initialise message application primary user package1
         val applicationInfo1 = ApplicationInfo()
         applicationInfo1.uid = PRIMARY_USER_SMS_APP_UID1
         doReturn(applicationInfo1)
-            .`when`(mPackageManager)
+            .`when`(mPackageManagerPrimaryUser)
             .getApplicationInfo(eq(SMS_APP1), anyInt())
 
-        // Initialise default message application primary user package2
+        // Initialise message application primary user package2
         val applicationInfo2 = ApplicationInfo()
         applicationInfo2.uid = PRIMARY_USER_SMS_APP_UID2
         doReturn(applicationInfo2)
-            .`when`(mPackageManager)
+            .`when`(mPackageManagerPrimaryUser)
             .getApplicationInfo(eq(SMS_APP2), anyInt())
 
-        // Get registered listener using captor
-        val listenerCaptor = ArgumentCaptor.forClass(
-            OnRoleHoldersChangedListener::class.java
-        )
-        mSatelliteAccessController.start()
-        verify(mRoleManager).addOnRoleHoldersChangedListenerAsUser(
-            any(Executor::class.java), listenerCaptor.capture(), any(UserHandle::class.java))
-        mRoleHolderChangedListener = listenerCaptor.value
+        // Initialise message application secondary user package1
+        val applicationInfo3 = ApplicationInfo()
+        applicationInfo3.uid = SECONDARY_USER_SMS_APP_UID1
+        doReturn(applicationInfo3)
+            .`when`(mPackageManagerSecondaryUser)
+            .getApplicationInfo(eq(SMS_APP1), anyInt())
+
+        // Initialise message application secondary user package2
+        val applicationInfo4 = ApplicationInfo()
+        applicationInfo4.uid = SECONDARY_USER_SMS_APP_UID2
+        doReturn(applicationInfo4)
+            .`when`(mPackageManagerSecondaryUser)
+            .getApplicationInfo(eq(SMS_APP2), anyInt())
     }
 
     @Test
     fun test_onRoleHoldersChanged_SatelliteFallbackUid_Changed_SingleUser() {
-        doReturn(listOf<String>()).`when`(mRoleManager).getRoleHoldersAsUser(RoleManager.ROLE_SMS,
-            PRIMARY_USER_HANDLE)
+        startSatelliteAccessController()
+        doReturn(listOf<String>()).`when`(mDeps).getRoleHoldersAsUser(
+            RoleManager.ROLE_SMS,
+            PRIMARY_USER_HANDLE
+        )
         mRoleHolderChangedListener.onRoleHoldersChanged(RoleManager.ROLE_SMS, PRIMARY_USER_HANDLE)
         verify(mCallback, never()).accept(any())
 
         // check DEFAULT_MESSAGING_APP1 is available as satellite network fallback uid
         doReturn(listOf(SMS_APP1))
-            .`when`(mRoleManager).getRoleHoldersAsUser(RoleManager.ROLE_SMS, PRIMARY_USER_HANDLE)
+            .`when`(mDeps).getRoleHoldersAsUser(RoleManager.ROLE_SMS, PRIMARY_USER_HANDLE)
         mRoleHolderChangedListener.onRoleHoldersChanged(RoleManager.ROLE_SMS, PRIMARY_USER_HANDLE)
         verify(mCallback).accept(setOf(PRIMARY_USER_SMS_APP_UID1))
 
         // check SMS_APP2 is available as satellite network Fallback uid
-        doReturn(listOf(SMS_APP2)).`when`(mRoleManager).getRoleHoldersAsUser(RoleManager.ROLE_SMS,
-            PRIMARY_USER_HANDLE)
+        doReturn(listOf(SMS_APP2)).`when`(mDeps).getRoleHoldersAsUser(
+            RoleManager.ROLE_SMS,
+            PRIMARY_USER_HANDLE
+        )
         mRoleHolderChangedListener.onRoleHoldersChanged(RoleManager.ROLE_SMS, PRIMARY_USER_HANDLE)
         verify(mCallback).accept(setOf(PRIMARY_USER_SMS_APP_UID2))
 
         // check no uid is available as satellite network fallback uid
-        doReturn(listOf<String>()).`when`(mRoleManager).getRoleHoldersAsUser(RoleManager.ROLE_SMS,
-            PRIMARY_USER_HANDLE)
+        doReturn(listOf<String>()).`when`(mDeps).getRoleHoldersAsUser(
+            RoleManager.ROLE_SMS,
+            PRIMARY_USER_HANDLE
+        )
         mRoleHolderChangedListener.onRoleHoldersChanged(RoleManager.ROLE_SMS, PRIMARY_USER_HANDLE)
         verify(mCallback).accept(ArraySet())
     }
 
     @Test
     fun test_onRoleHoldersChanged_NoSatelliteCommunicationPermission() {
-        doReturn(listOf<Any>()).`when`(mRoleManager).getRoleHoldersAsUser(RoleManager.ROLE_SMS,
-            PRIMARY_USER_HANDLE)
+        startSatelliteAccessController()
+        doReturn(listOf<Any>()).`when`(mDeps).getRoleHoldersAsUser(
+            RoleManager.ROLE_SMS,
+            PRIMARY_USER_HANDLE
+        )
         mRoleHolderChangedListener.onRoleHoldersChanged(RoleManager.ROLE_SMS, PRIMARY_USER_HANDLE)
         verify(mCallback, never()).accept(any())
 
         // check DEFAULT_MESSAGING_APP1 is not available as satellite network fallback uid
         // since satellite communication permission not available.
         doReturn(PackageManager.PERMISSION_DENIED)
-            .`when`(mPackageManager)
+            .`when`(mPackageManagerPrimaryUser)
             .checkPermission(Manifest.permission.SATELLITE_COMMUNICATION, SMS_APP1)
         doReturn(listOf(SMS_APP1))
-            .`when`(mRoleManager).getRoleHoldersAsUser(RoleManager.ROLE_SMS, PRIMARY_USER_HANDLE)
+            .`when`(mDeps).getRoleHoldersAsUser(RoleManager.ROLE_SMS, PRIMARY_USER_HANDLE)
         mRoleHolderChangedListener.onRoleHoldersChanged(RoleManager.ROLE_SMS, PRIMARY_USER_HANDLE)
         verify(mCallback, never()).accept(any())
     }
 
     @Test
     fun test_onRoleHoldersChanged_RoleSms_NotAvailable() {
+        startSatelliteAccessController()
         doReturn(listOf(SMS_APP1))
-            .`when`(mRoleManager).getRoleHoldersAsUser(RoleManager.ROLE_SMS, PRIMARY_USER_HANDLE)
-        mRoleHolderChangedListener.onRoleHoldersChanged(RoleManager.ROLE_BROWSER,
-            PRIMARY_USER_HANDLE)
+            .`when`(mDeps).getRoleHoldersAsUser(RoleManager.ROLE_SMS, PRIMARY_USER_HANDLE)
+        mRoleHolderChangedListener.onRoleHoldersChanged(
+            RoleManager.ROLE_BROWSER,
+            PRIMARY_USER_HANDLE
+        )
         verify(mCallback, never()).accept(any())
     }
 
     @Test
     fun test_onRoleHoldersChanged_SatelliteNetworkFallbackUid_Changed_multiUser() {
-        doReturn(listOf<String>()).`when`(mRoleManager).getRoleHoldersAsUser(RoleManager.ROLE_SMS,
-            PRIMARY_USER_HANDLE)
+        startSatelliteAccessController()
+        doReturn(listOf<String>()).`when`(mDeps).getRoleHoldersAsUser(
+            RoleManager.ROLE_SMS,
+            PRIMARY_USER_HANDLE
+        )
         mRoleHolderChangedListener.onRoleHoldersChanged(RoleManager.ROLE_SMS, PRIMARY_USER_HANDLE)
         verify(mCallback, never()).accept(any())
 
         // check SMS_APP1 is available as satellite network fallback uid at primary user
         doReturn(listOf(SMS_APP1))
-            .`when`(mRoleManager).getRoleHoldersAsUser(RoleManager.ROLE_SMS, PRIMARY_USER_HANDLE)
+            .`when`(mDeps).getRoleHoldersAsUser(RoleManager.ROLE_SMS, PRIMARY_USER_HANDLE)
         mRoleHolderChangedListener.onRoleHoldersChanged(RoleManager.ROLE_SMS, PRIMARY_USER_HANDLE)
         verify(mCallback).accept(setOf(PRIMARY_USER_SMS_APP_UID1))
 
         // check SMS_APP2 is available as satellite network fallback uid at primary user
-        doReturn(listOf(SMS_APP2)).`when`(mRoleManager).getRoleHoldersAsUser(RoleManager.ROLE_SMS,
-            PRIMARY_USER_HANDLE)
+        doReturn(listOf(SMS_APP2)).`when`(mDeps).getRoleHoldersAsUser(
+            RoleManager.ROLE_SMS,
+            PRIMARY_USER_HANDLE
+        )
         mRoleHolderChangedListener.onRoleHoldersChanged(RoleManager.ROLE_SMS, PRIMARY_USER_HANDLE)
         verify(mCallback).accept(setOf(PRIMARY_USER_SMS_APP_UID2))
 
         // check SMS_APP1 is available as satellite network fallback uid at secondary user
-        val applicationInfo1 = ApplicationInfo()
-        applicationInfo1.uid = SECONDARY_USER_SMS_APP_UID1
-        doReturn(applicationInfo1).`when`(mPackageManager)
-            .getApplicationInfo(eq(SMS_APP1), anyInt())
-        doReturn(listOf(SMS_APP1)).`when`(mRoleManager).getRoleHoldersAsUser(RoleManager.ROLE_SMS,
-            SECONDARY_USER_HANDLE)
+        doReturn(listOf(SMS_APP1)).`when`(mDeps).getRoleHoldersAsUser(
+            RoleManager.ROLE_SMS,
+            SECONDARY_USER_HANDLE
+        )
         mRoleHolderChangedListener.onRoleHoldersChanged(RoleManager.ROLE_SMS, SECONDARY_USER_HANDLE)
         verify(mCallback).accept(setOf(PRIMARY_USER_SMS_APP_UID2, SECONDARY_USER_SMS_APP_UID1))
 
         // check no uid is available as satellite network fallback uid at primary user
-        doReturn(listOf<String>()).`when`(mRoleManager).getRoleHoldersAsUser(RoleManager.ROLE_SMS,
-            PRIMARY_USER_HANDLE)
-        mRoleHolderChangedListener.onRoleHoldersChanged(RoleManager.ROLE_SMS,
-            PRIMARY_USER_HANDLE)
+        doReturn(listOf<String>()).`when`(mDeps).getRoleHoldersAsUser(
+            RoleManager.ROLE_SMS,
+            PRIMARY_USER_HANDLE
+        )
+        mRoleHolderChangedListener.onRoleHoldersChanged(
+            RoleManager.ROLE_SMS,
+            PRIMARY_USER_HANDLE
+        )
         verify(mCallback).accept(setOf(SECONDARY_USER_SMS_APP_UID1))
 
         // check SMS_APP2 is available as satellite network fallback uid at secondary user
-        applicationInfo1.uid = SECONDARY_USER_SMS_APP_UID2
-        doReturn(applicationInfo1).`when`(mPackageManager)
-            .getApplicationInfo(eq(SMS_APP2), anyInt())
         doReturn(listOf(SMS_APP2))
-            .`when`(mRoleManager).getRoleHoldersAsUser(RoleManager.ROLE_SMS, SECONDARY_USER_HANDLE)
+            .`when`(mDeps).getRoleHoldersAsUser(RoleManager.ROLE_SMS, SECONDARY_USER_HANDLE)
         mRoleHolderChangedListener.onRoleHoldersChanged(RoleManager.ROLE_SMS, SECONDARY_USER_HANDLE)
         verify(mCallback).accept(setOf(SECONDARY_USER_SMS_APP_UID2))
 
         // check no uid is available as satellite network fallback uid at secondary user
-        doReturn(listOf<String>()).`when`(mRoleManager).getRoleHoldersAsUser(RoleManager.ROLE_SMS,
-            SECONDARY_USER_HANDLE)
+        doReturn(listOf<String>()).`when`(mDeps).getRoleHoldersAsUser(
+            RoleManager.ROLE_SMS,
+            SECONDARY_USER_HANDLE
+        )
         mRoleHolderChangedListener.onRoleHoldersChanged(RoleManager.ROLE_SMS, SECONDARY_USER_HANDLE)
         verify(mCallback).accept(ArraySet())
     }
+
+    @Test
+    fun test_SatelliteFallbackUidCallback_OnUserRemoval() {
+        startSatelliteAccessController()
+        // check SMS_APP2 is available as satellite network fallback uid at primary user
+        doReturn(listOf(SMS_APP2)).`when`(mDeps).getRoleHoldersAsUser(
+            RoleManager.ROLE_SMS,
+            PRIMARY_USER_HANDLE
+        )
+        mRoleHolderChangedListener.onRoleHoldersChanged(RoleManager.ROLE_SMS, PRIMARY_USER_HANDLE)
+        verify(mCallback).accept(setOf(PRIMARY_USER_SMS_APP_UID2))
+
+        // check SMS_APP1 is available as satellite network fallback uid at secondary user
+        doReturn(listOf(SMS_APP1)).`when`(mDeps).getRoleHoldersAsUser(
+            RoleManager.ROLE_SMS,
+            SECONDARY_USER_HANDLE
+        )
+        mRoleHolderChangedListener.onRoleHoldersChanged(RoleManager.ROLE_SMS, SECONDARY_USER_HANDLE)
+        verify(mCallback).accept(setOf(PRIMARY_USER_SMS_APP_UID2, SECONDARY_USER_SMS_APP_UID1))
+
+        val userRemovalIntent = Intent(Intent.ACTION_USER_REMOVED)
+        userRemovalIntent.putExtra(Intent.EXTRA_USER, SECONDARY_USER_HANDLE)
+        mUserRemovedReceiver.onReceive(context, userRemovalIntent)
+        verify(mCallback, times(2)).accept(setOf(PRIMARY_USER_SMS_APP_UID2))
+    }
+
+    @Test
+    fun testOnStartUpCallbackSatelliteFallbackUidWithExistingUsers() {
+        doReturn(
+            listOf(PRIMARY_USER_HANDLE)
+        ).`when`(userManager).getUserHandles(true)
+        doReturn(listOf(SMS_APP1))
+            .`when`(mDeps).getRoleHoldersAsUser(RoleManager.ROLE_SMS, PRIMARY_USER_HANDLE)
+        // At start up, SatelliteAccessController must call CS callback with existing users'
+        // default messaging apps uids.
+        startSatelliteAccessController()
+        verify(mCallback, timeout(500)).accept(setOf(PRIMARY_USER_SMS_APP_UID1))
+    }
+
+    private fun startSatelliteAccessController() {
+        mSatelliteAccessController.start()
+        // Get registered listener using captor
+        val listenerCaptor = ArgumentCaptor.forClass(OnRoleHoldersChangedListener::class.java)
+        verify(mDeps).addOnRoleHoldersChangedListenerAsUser(
+            any(Executor::class.java),
+            listenerCaptor.capture(),
+            any(UserHandle::class.java)
+        )
+        mRoleHolderChangedListener = listenerCaptor.value
+
+        // Get registered receiver using captor
+        val userRemovedReceiverCaptor = ArgumentCaptor.forClass(BroadcastReceiver::class.java)
+        verify(context).registerReceiver(
+            userRemovedReceiverCaptor.capture(),
+            any(IntentFilter::class.java),
+            isNull(),
+            any(Handler::class.java)
+        )
+         mUserRemovedReceiver = userRemovedReceiverCaptor.value
+    }
 }
diff --git a/tests/unit/java/com/android/server/connectivity/VpnTest.java b/tests/unit/java/com/android/server/connectivity/VpnTest.java
deleted file mode 100644
index c9cece0..0000000
--- a/tests/unit/java/com/android/server/connectivity/VpnTest.java
+++ /dev/null
@@ -1,3298 +0,0 @@
-/*
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.android.server.connectivity;
-
-import static android.Manifest.permission.BIND_VPN_SERVICE;
-import static android.Manifest.permission.CONTROL_VPN;
-import static android.content.pm.PackageManager.PERMISSION_DENIED;
-import static android.content.pm.PackageManager.PERMISSION_GRANTED;
-import static android.net.ConnectivityDiagnosticsManager.ConnectivityDiagnosticsCallback;
-import static android.net.ConnectivityDiagnosticsManager.DataStallReport;
-import static android.net.ConnectivityManager.NetworkCallback;
-import static android.net.INetd.IF_STATE_DOWN;
-import static android.net.INetd.IF_STATE_UP;
-import static android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET;
-import static android.net.NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED;
-import static android.net.NetworkCapabilities.NET_CAPABILITY_TEMPORARILY_NOT_METERED;
-import static android.net.NetworkCapabilities.TRANSPORT_CELLULAR;
-import static android.net.NetworkCapabilities.TRANSPORT_WIFI;
-import static android.net.RouteInfo.RTN_UNREACHABLE;
-import static android.net.VpnManager.TYPE_VPN_PLATFORM;
-import static android.net.cts.util.IkeSessionTestUtils.CHILD_PARAMS;
-import static android.net.cts.util.IkeSessionTestUtils.TEST_IDENTITY;
-import static android.net.cts.util.IkeSessionTestUtils.TEST_KEEPALIVE_TIMEOUT_UNSET;
-import static android.net.cts.util.IkeSessionTestUtils.getTestIkeSessionParams;
-import static android.net.ipsec.ike.IkeSessionConfiguration.EXTENSION_TYPE_MOBIKE;
-import static android.net.ipsec.ike.IkeSessionParams.ESP_ENCAP_TYPE_AUTO;
-import static android.net.ipsec.ike.IkeSessionParams.ESP_ENCAP_TYPE_NONE;
-import static android.net.ipsec.ike.IkeSessionParams.ESP_ENCAP_TYPE_UDP;
-import static android.net.ipsec.ike.IkeSessionParams.ESP_IP_VERSION_AUTO;
-import static android.net.ipsec.ike.IkeSessionParams.ESP_IP_VERSION_IPV4;
-import static android.net.ipsec.ike.IkeSessionParams.ESP_IP_VERSION_IPV6;
-import static android.os.UserHandle.PER_USER_RANGE;
-import static android.telephony.CarrierConfigManager.KEY_CARRIER_CONFIG_APPLIED_BOOL;
-import static android.telephony.CarrierConfigManager.KEY_MIN_UDP_PORT_4500_NAT_TIMEOUT_SEC_INT;
-import static android.telephony.CarrierConfigManager.KEY_PREFERRED_IKE_PROTOCOL_INT;
-
-import static com.android.net.module.util.NetworkStackConstants.IPV6_MIN_MTU;
-import static com.android.server.connectivity.Vpn.AUTOMATIC_KEEPALIVE_DELAY_SECONDS;
-import static com.android.server.connectivity.Vpn.DEFAULT_LONG_LIVED_TCP_CONNS_EXPENSIVE_TIMEOUT_SEC;
-import static com.android.server.connectivity.Vpn.DEFAULT_UDP_PORT_4500_NAT_TIMEOUT_SEC_INT;
-import static com.android.server.connectivity.Vpn.PREFERRED_IKE_PROTOCOL_AUTO;
-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.HandlerUtils.waitForIdleSerialExecutor;
-import static com.android.testutils.MiscAsserts.assertThrows;
-
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertFalse;
-import static org.junit.Assert.assertNotNull;
-import static org.junit.Assert.assertNull;
-import static org.junit.Assert.assertTrue;
-import static org.junit.Assert.fail;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.anyBoolean;
-import static org.mockito.ArgumentMatchers.anyInt;
-import static org.mockito.ArgumentMatchers.anyLong;
-import static org.mockito.ArgumentMatchers.anyString;
-import static org.mockito.ArgumentMatchers.argThat;
-import static org.mockito.ArgumentMatchers.eq;
-import static org.mockito.ArgumentMatchers.longThat;
-import static org.mockito.Mockito.after;
-import static org.mockito.Mockito.atLeast;
-import static org.mockito.Mockito.atLeastOnce;
-import static org.mockito.Mockito.clearInvocations;
-import static org.mockito.Mockito.doAnswer;
-import static org.mockito.Mockito.doCallRealMethod;
-import static org.mockito.Mockito.doNothing;
-import static org.mockito.Mockito.doReturn;
-import static org.mockito.Mockito.doThrow;
-import static org.mockito.Mockito.inOrder;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.never;
-import static org.mockito.Mockito.reset;
-import static org.mockito.Mockito.spy;
-import static org.mockito.Mockito.timeout;
-import static org.mockito.Mockito.times;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
-
-import android.annotation.NonNull;
-import android.annotation.UserIdInt;
-import android.app.AppOpsManager;
-import android.app.NotificationManager;
-import android.app.PendingIntent;
-import android.content.Context;
-import android.content.Intent;
-import android.content.pm.ApplicationInfo;
-import android.content.pm.PackageManager;
-import android.content.pm.ResolveInfo;
-import android.content.pm.ServiceInfo;
-import android.content.pm.UserInfo;
-import android.content.res.Resources;
-import android.net.ConnectivityDiagnosticsManager;
-import android.net.ConnectivityManager;
-import android.net.INetd;
-import android.net.Ikev2VpnProfile;
-import android.net.InetAddresses;
-import android.net.InterfaceConfigurationParcel;
-import android.net.IpPrefix;
-import android.net.IpSecConfig;
-import android.net.IpSecManager;
-import android.net.IpSecTransform;
-import android.net.IpSecTunnelInterfaceResponse;
-import android.net.LinkAddress;
-import android.net.LinkProperties;
-import android.net.Network;
-import android.net.NetworkAgent;
-import android.net.NetworkAgentConfig;
-import android.net.NetworkCapabilities;
-import android.net.NetworkInfo.DetailedState;
-import android.net.RouteInfo;
-import android.net.TelephonyNetworkSpecifier;
-import android.net.UidRangeParcel;
-import android.net.VpnManager;
-import android.net.VpnProfileState;
-import android.net.VpnService;
-import android.net.VpnTransportInfo;
-import android.net.ipsec.ike.ChildSessionCallback;
-import android.net.ipsec.ike.ChildSessionConfiguration;
-import android.net.ipsec.ike.IkeFqdnIdentification;
-import android.net.ipsec.ike.IkeSessionCallback;
-import android.net.ipsec.ike.IkeSessionConfiguration;
-import android.net.ipsec.ike.IkeSessionConnectionInfo;
-import android.net.ipsec.ike.IkeSessionParams;
-import android.net.ipsec.ike.IkeTrafficSelector;
-import android.net.ipsec.ike.IkeTunnelConnectionParams;
-import android.net.ipsec.ike.exceptions.IkeException;
-import android.net.ipsec.ike.exceptions.IkeNetworkLostException;
-import android.net.ipsec.ike.exceptions.IkeNonProtocolException;
-import android.net.ipsec.ike.exceptions.IkeProtocolException;
-import android.net.ipsec.ike.exceptions.IkeTimeoutException;
-import android.net.vcn.VcnTransportInfo;
-import android.net.wifi.WifiInfo;
-import android.os.Build.VERSION_CODES;
-import android.os.Bundle;
-import android.os.INetworkManagementService;
-import android.os.ParcelFileDescriptor;
-import android.os.PersistableBundle;
-import android.os.PowerWhitelistManager;
-import android.os.Process;
-import android.os.UserHandle;
-import android.os.UserManager;
-import android.os.test.TestLooper;
-import android.provider.Settings;
-import android.security.Credentials;
-import android.telephony.CarrierConfigManager;
-import android.telephony.SubscriptionInfo;
-import android.telephony.SubscriptionManager;
-import android.telephony.TelephonyManager;
-import android.util.ArrayMap;
-import android.util.ArraySet;
-import android.util.Pair;
-import android.util.Range;
-
-import androidx.test.ext.junit.runners.AndroidJUnit4;
-import androidx.test.filters.SmallTest;
-
-import com.android.internal.R;
-import com.android.internal.net.LegacyVpnInfo;
-import com.android.internal.net.VpnConfig;
-import com.android.internal.net.VpnProfile;
-import com.android.internal.util.HexDump;
-import com.android.internal.util.IndentingPrintWriter;
-import com.android.server.DeviceIdleInternal;
-import com.android.server.IpSecService;
-import com.android.server.VpnTestBase;
-import com.android.server.vcn.util.PersistableBundleUtils;
-import com.android.testutils.DevSdkIgnoreRule;
-
-import org.junit.Before;
-import org.junit.Rule;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-import org.mockito.AdditionalAnswers;
-import org.mockito.Answers;
-import org.mockito.ArgumentCaptor;
-import org.mockito.Captor;
-import org.mockito.InOrder;
-import org.mockito.Mock;
-import org.mockito.MockitoAnnotations;
-
-import java.io.FileDescriptor;
-import java.io.IOException;
-import java.io.StringWriter;
-import java.net.Inet4Address;
-import java.net.Inet6Address;
-import java.net.InetAddress;
-import java.net.UnknownHostException;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.SortedSet;
-import java.util.TreeSet;
-import java.util.concurrent.ScheduledFuture;
-import java.util.concurrent.ScheduledThreadPoolExecutor;
-import java.util.concurrent.TimeUnit;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-
-/**
- * Tests for {@link Vpn}.
- *
- * Build, install and run with:
- *  runtest frameworks-net -c com.android.server.connectivity.VpnTest
- */
-@RunWith(AndroidJUnit4.class)
-@SmallTest
-public class VpnTest extends VpnTestBase {
-    private static final String TAG = "VpnTest";
-
-    @Rule
-    public final DevSdkIgnoreRule mIgnoreRule = new DevSdkIgnoreRule();
-
-    static final Network EGRESS_NETWORK = new Network(101);
-    static final String EGRESS_IFACE = "wlan0";
-    private static final String TEST_VPN_CLIENT = "2.4.6.8";
-    private static final String TEST_VPN_SERVER = "1.2.3.4";
-    private static final String TEST_VPN_IDENTITY = "identity";
-    private static final byte[] TEST_VPN_PSK = "psk".getBytes();
-
-    private static final int IP4_PREFIX_LEN = 32;
-    private static final int IP6_PREFIX_LEN = 64;
-    private static final int MIN_PORT = 0;
-    private static final int MAX_PORT = 65535;
-
-    private static final InetAddress TEST_VPN_CLIENT_IP =
-            InetAddresses.parseNumericAddress(TEST_VPN_CLIENT);
-    private static final InetAddress TEST_VPN_SERVER_IP =
-            InetAddresses.parseNumericAddress(TEST_VPN_SERVER);
-    private static final InetAddress TEST_VPN_CLIENT_IP_2 =
-            InetAddresses.parseNumericAddress("192.0.2.200");
-    private static final InetAddress TEST_VPN_SERVER_IP_2 =
-            InetAddresses.parseNumericAddress("192.0.2.201");
-    private static final InetAddress TEST_VPN_INTERNAL_IP =
-            InetAddresses.parseNumericAddress("198.51.100.10");
-    private static final InetAddress TEST_VPN_INTERNAL_IP6 =
-            InetAddresses.parseNumericAddress("2001:db8::1");
-    private static final InetAddress TEST_VPN_INTERNAL_DNS =
-            InetAddresses.parseNumericAddress("8.8.8.8");
-    private static final InetAddress TEST_VPN_INTERNAL_DNS6 =
-            InetAddresses.parseNumericAddress("2001:4860:4860::8888");
-
-    private static final IkeTrafficSelector IN_TS =
-            new IkeTrafficSelector(MIN_PORT, MAX_PORT, TEST_VPN_INTERNAL_IP, TEST_VPN_INTERNAL_IP);
-    private static final IkeTrafficSelector IN_TS6 =
-            new IkeTrafficSelector(
-                    MIN_PORT, MAX_PORT, TEST_VPN_INTERNAL_IP6, TEST_VPN_INTERNAL_IP6);
-    private static final IkeTrafficSelector OUT_TS =
-            new IkeTrafficSelector(MIN_PORT, MAX_PORT,
-                    InetAddresses.parseNumericAddress("0.0.0.0"),
-                    InetAddresses.parseNumericAddress("255.255.255.255"));
-    private static final IkeTrafficSelector OUT_TS6 =
-            new IkeTrafficSelector(
-                    MIN_PORT,
-                    MAX_PORT,
-                    InetAddresses.parseNumericAddress("::"),
-                    InetAddresses.parseNumericAddress("ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff"));
-
-    private static final Network TEST_NETWORK = new Network(Integer.MAX_VALUE);
-    private static final Network TEST_NETWORK_2 = new Network(Integer.MAX_VALUE - 1);
-    private static final String TEST_IFACE_NAME = "TEST_IFACE";
-    private static final int TEST_TUNNEL_RESOURCE_ID = 0x2345;
-    private static final long TEST_TIMEOUT_MS = 500L;
-    private static final long TIMEOUT_CROSSTHREAD_MS = 20_000L;
-    private static final String PRIMARY_USER_APP_EXCLUDE_KEY =
-            "VPNAPPEXCLUDED_27_com.testvpn.vpn";
-    static final String PKGS_BYTES = getPackageByteString(List.of(PKGS));
-    private static final Range<Integer> PRIMARY_USER_RANGE = uidRangeForUser(PRIMARY_USER.id);
-    private static final int TEST_KEEPALIVE_TIMER = 800;
-    private static final int TEST_SUB_ID = 1234;
-    private static final String TEST_MCCMNC = "12345";
-
-    @Mock(answer = Answers.RETURNS_DEEP_STUBS) private Context mContext;
-    @Mock private UserManager mUserManager;
-    @Mock private PackageManager mPackageManager;
-    @Mock private INetworkManagementService mNetService;
-    @Mock private INetd mNetd;
-    @Mock private AppOpsManager mAppOps;
-    @Mock private NotificationManager mNotificationManager;
-    @Mock private Vpn.SystemServices mSystemServices;
-    @Mock private Vpn.IkeSessionWrapper mIkeSessionWrapper;
-    @Mock private Vpn.Ikev2SessionCreator mIkev2SessionCreator;
-    @Mock private Vpn.VpnNetworkAgentWrapper mMockNetworkAgent;
-    @Mock private ConnectivityManager mConnectivityManager;
-    @Mock private ConnectivityDiagnosticsManager mCdm;
-    @Mock private TelephonyManager mTelephonyManager;
-    @Mock private TelephonyManager mTmPerSub;
-    @Mock private CarrierConfigManager mConfigManager;
-    @Mock private SubscriptionManager mSubscriptionManager;
-    @Mock private IpSecService mIpSecService;
-    @Mock private VpnProfileStore mVpnProfileStore;
-    private final TestExecutor mExecutor;
-    @Mock DeviceIdleInternal mDeviceIdleInternal;
-    private final VpnProfile mVpnProfile;
-
-    @Captor private ArgumentCaptor<Collection<Range<Integer>>> mUidRangesCaptor;
-
-    private IpSecManager mIpSecManager;
-    private TestDeps mTestDeps;
-
-    public static class TestExecutor extends ScheduledThreadPoolExecutor {
-        public static final long REAL_DELAY = -1;
-
-        // For the purposes of the test, run all scheduled tasks after 10ms to save
-        // execution time, unless overridden by the specific test. Set to REAL_DELAY
-        // to actually wait for the delay specified by the real call to schedule().
-        public long delayMs = 10;
-        // If this is true, execute() will call the runnable inline. This is useful because
-        // super.execute() calls schedule(), which messes with checks that scheduled() is
-        // called a given number of times.
-        public boolean executeDirect = false;
-
-        public TestExecutor() {
-            super(1);
-        }
-
-        @Override
-        public void execute(final Runnable command) {
-            // See |executeDirect| for why this is necessary.
-            if (executeDirect) {
-                command.run();
-            } else {
-                super.execute(command);
-            }
-        }
-
-        @Override
-        public ScheduledFuture<?> schedule(final Runnable command, final long delay,
-                TimeUnit unit) {
-            if (0 == delay || delayMs == REAL_DELAY) {
-                // super.execute() calls schedule() with 0, so use the real delay if it's 0.
-                return super.schedule(command, delay, unit);
-            } else {
-                return super.schedule(command, delayMs, TimeUnit.MILLISECONDS);
-            }
-        }
-    }
-
-    public VpnTest() throws Exception {
-        // Build an actual VPN profile that is capable of being converted to and from an
-        // Ikev2VpnProfile
-        final Ikev2VpnProfile.Builder builder =
-                new Ikev2VpnProfile.Builder(TEST_VPN_SERVER, TEST_VPN_IDENTITY);
-        builder.setAuthPsk(TEST_VPN_PSK);
-        builder.setBypassable(true /* isBypassable */);
-        mExecutor = spy(new TestExecutor());
-        mVpnProfile = builder.build().toVpnProfile();
-    }
-
-    @Before
-    public void setUp() throws Exception {
-        MockitoAnnotations.initMocks(this);
-
-        mIpSecManager = new IpSecManager(mContext, mIpSecService);
-        mTestDeps = spy(new TestDeps());
-        doReturn(IPV6_MIN_MTU)
-                .when(mTestDeps)
-                .calculateVpnMtu(any(), anyInt(), anyInt(), anyBoolean());
-        doReturn(1500).when(mTestDeps).getJavaNetworkInterfaceMtu(any(), anyInt());
-
-        when(mContext.getPackageManager()).thenReturn(mPackageManager);
-        setMockedPackages(sPackages);
-
-        when(mContext.getPackageName()).thenReturn(TEST_VPN_PKG);
-        when(mContext.getOpPackageName()).thenReturn(TEST_VPN_PKG);
-        mockService(UserManager.class, Context.USER_SERVICE, mUserManager);
-        mockService(AppOpsManager.class, Context.APP_OPS_SERVICE, mAppOps);
-        mockService(NotificationManager.class, Context.NOTIFICATION_SERVICE, mNotificationManager);
-        mockService(ConnectivityManager.class, Context.CONNECTIVITY_SERVICE, mConnectivityManager);
-        mockService(IpSecManager.class, Context.IPSEC_SERVICE, mIpSecManager);
-        mockService(ConnectivityDiagnosticsManager.class, Context.CONNECTIVITY_DIAGNOSTICS_SERVICE,
-                mCdm);
-        mockService(TelephonyManager.class, Context.TELEPHONY_SERVICE, mTelephonyManager);
-        mockService(CarrierConfigManager.class, Context.CARRIER_CONFIG_SERVICE, mConfigManager);
-        mockService(SubscriptionManager.class, Context.TELEPHONY_SUBSCRIPTION_SERVICE,
-                mSubscriptionManager);
-        doReturn(mTmPerSub).when(mTelephonyManager).createForSubscriptionId(anyInt());
-        when(mContext.getString(R.string.config_customVpnAlwaysOnDisconnectedDialogComponent))
-                .thenReturn(Resources.getSystem().getString(
-                        R.string.config_customVpnAlwaysOnDisconnectedDialogComponent));
-        when(mPackageManager.hasSystemFeature(PackageManager.FEATURE_IPSEC_TUNNELS))
-                .thenReturn(true);
-
-        // Used by {@link Notification.Builder}
-        ApplicationInfo applicationInfo = new ApplicationInfo();
-        applicationInfo.targetSdkVersion = VERSION_CODES.CUR_DEVELOPMENT;
-        when(mContext.getApplicationInfo()).thenReturn(applicationInfo);
-        when(mPackageManager.getApplicationInfoAsUser(anyString(), anyInt(), anyInt()))
-                .thenReturn(applicationInfo);
-
-        doNothing().when(mNetService).registerObserver(any());
-
-        // Deny all appops by default.
-        when(mAppOps.noteOpNoThrow(anyString(), anyInt(), anyString(), any(), any()))
-                .thenReturn(AppOpsManager.MODE_IGNORED);
-
-        // Setup IpSecService
-        final IpSecTunnelInterfaceResponse tunnelResp =
-                new IpSecTunnelInterfaceResponse(
-                        IpSecManager.Status.OK, TEST_TUNNEL_RESOURCE_ID, TEST_IFACE_NAME);
-        when(mIpSecService.createTunnelInterface(any(), any(), any(), any(), any()))
-                .thenReturn(tunnelResp);
-        doReturn(new LinkProperties()).when(mConnectivityManager).getLinkProperties(any());
-
-        // The unit test should know what kind of permission it needs and set the permission by
-        // itself, so set the default value of Context#checkCallingOrSelfPermission to
-        // PERMISSION_DENIED.
-        doReturn(PERMISSION_DENIED).when(mContext).checkCallingOrSelfPermission(any());
-
-        // Set up mIkev2SessionCreator and mExecutor
-        resetIkev2SessionCreator(mIkeSessionWrapper);
-    }
-
-    private void resetIkev2SessionCreator(Vpn.IkeSessionWrapper ikeSession) {
-        reset(mIkev2SessionCreator);
-        when(mIkev2SessionCreator.createIkeSession(any(), any(), any(), any(), any(), any()))
-                .thenReturn(ikeSession);
-    }
-
-    private <T> void mockService(Class<T> clazz, String name, T service) {
-        doReturn(service).when(mContext).getSystemService(name);
-        doReturn(name).when(mContext).getSystemServiceName(clazz);
-        if (mContext.getSystemService(clazz).getClass().equals(Object.class)) {
-            // Test is using mockito-extended (mContext uses Answers.RETURNS_DEEP_STUBS and returned
-            // a mock object on a final method)
-            doCallRealMethod().when(mContext).getSystemService(clazz);
-        }
-    }
-
-    private Set<Range<Integer>> rangeSet(Range<Integer> ... ranges) {
-        final Set<Range<Integer>> range = new ArraySet<>();
-        for (Range<Integer> r : ranges) range.add(r);
-
-        return range;
-    }
-
-    private static Range<Integer> uidRangeForUser(int userId) {
-        return new Range<Integer>(userId * PER_USER_RANGE, (userId + 1) * PER_USER_RANGE - 1);
-    }
-
-    private Range<Integer> uidRange(int start, int stop) {
-        return new Range<Integer>(start, stop);
-    }
-
-    private static String getPackageByteString(List<String> packages) {
-        try {
-            return HexDump.toHexString(
-                    PersistableBundleUtils.toDiskStableBytes(PersistableBundleUtils.fromList(
-                            packages, PersistableBundleUtils.STRING_SERIALIZER)),
-                        true /* upperCase */);
-        } catch (IOException e) {
-            return null;
-        }
-    }
-
-    @Test
-    public void testRestrictedProfilesAreAddedToVpn() {
-        setMockedUsers(PRIMARY_USER, SECONDARY_USER, RESTRICTED_PROFILE_A, RESTRICTED_PROFILE_B);
-
-        final Vpn vpn = createVpn(PRIMARY_USER.id);
-
-        // Assume the user can have restricted profiles.
-        doReturn(true).when(mUserManager).canHaveRestrictedProfile();
-        final Set<Range<Integer>> ranges =
-                vpn.createUserAndRestrictedProfilesRanges(PRIMARY_USER.id, null, null);
-
-        assertEquals(rangeSet(PRIMARY_USER_RANGE, uidRangeForUser(RESTRICTED_PROFILE_A.id)),
-                 ranges);
-    }
-
-    @Test
-    public void testManagedProfilesAreNotAddedToVpn() {
-        setMockedUsers(PRIMARY_USER, MANAGED_PROFILE_A);
-
-        final Vpn vpn = createVpn(PRIMARY_USER.id);
-        final Set<Range<Integer>> ranges = vpn.createUserAndRestrictedProfilesRanges(
-                PRIMARY_USER.id, null, null);
-
-        assertEquals(rangeSet(PRIMARY_USER_RANGE), ranges);
-    }
-
-    @Test
-    public void testAddUserToVpnOnlyAddsOneUser() {
-        setMockedUsers(PRIMARY_USER, RESTRICTED_PROFILE_A, MANAGED_PROFILE_A);
-
-        final Vpn vpn = createVpn(PRIMARY_USER.id);
-        final Set<Range<Integer>> ranges = new ArraySet<>();
-        vpn.addUserToRanges(ranges, PRIMARY_USER.id, null, null);
-
-        assertEquals(rangeSet(PRIMARY_USER_RANGE), ranges);
-    }
-
-    @Test
-    public void testUidAllowAndDenylist() throws Exception {
-        final Vpn vpn = createVpn(PRIMARY_USER.id);
-        final Range<Integer> user = PRIMARY_USER_RANGE;
-        final int userStart = user.getLower();
-        final int userStop = user.getUpper();
-        final String[] packages = {PKGS[0], PKGS[1], PKGS[2]};
-
-        // Allowed list
-        final Set<Range<Integer>> allow = vpn.createUserAndRestrictedProfilesRanges(PRIMARY_USER.id,
-                Arrays.asList(packages), null /* disallowedApplications */);
-        assertEquals(rangeSet(
-                uidRange(userStart + PKG_UIDS[0], userStart + PKG_UIDS[0]),
-                uidRange(userStart + PKG_UIDS[1], userStart + PKG_UIDS[2]),
-                uidRange(Process.toSdkSandboxUid(userStart + PKG_UIDS[0]),
-                         Process.toSdkSandboxUid(userStart + PKG_UIDS[0])),
-                uidRange(Process.toSdkSandboxUid(userStart + PKG_UIDS[1]),
-                         Process.toSdkSandboxUid(userStart + PKG_UIDS[2]))),
-                allow);
-
-        // Denied list
-        final Set<Range<Integer>> disallow =
-                vpn.createUserAndRestrictedProfilesRanges(PRIMARY_USER.id,
-                        null /* allowedApplications */, Arrays.asList(packages));
-        assertEquals(rangeSet(
-                uidRange(userStart, userStart + PKG_UIDS[0] - 1),
-                uidRange(userStart + PKG_UIDS[0] + 1, userStart + PKG_UIDS[1] - 1),
-                /* Empty range between UIDS[1] and UIDS[2], should be excluded, */
-                uidRange(userStart + PKG_UIDS[2] + 1,
-                         Process.toSdkSandboxUid(userStart + PKG_UIDS[0] - 1)),
-                uidRange(Process.toSdkSandboxUid(userStart + PKG_UIDS[0] + 1),
-                         Process.toSdkSandboxUid(userStart + PKG_UIDS[1] - 1)),
-                uidRange(Process.toSdkSandboxUid(userStart + PKG_UIDS[2] + 1), userStop)),
-                disallow);
-    }
-
-    private void verifyPowerSaveTempWhitelistApp(String packageName) {
-        verify(mDeviceIdleInternal, timeout(TEST_TIMEOUT_MS)).addPowerSaveTempWhitelistApp(
-                anyInt(), eq(packageName), anyLong(), anyInt(), eq(false),
-                eq(PowerWhitelistManager.REASON_VPN), eq("VpnManager event"));
-    }
-
-    @Test
-    public void testGetAlwaysAndOnGetLockDown() throws Exception {
-        final Vpn vpn = createVpn(PRIMARY_USER.id);
-
-        // Default state.
-        assertFalse(vpn.getAlwaysOn());
-        assertFalse(vpn.getLockdown());
-
-        // Set always-on without lockdown.
-        assertTrue(vpn.setAlwaysOnPackage(PKGS[1], false, Collections.emptyList()));
-        assertTrue(vpn.getAlwaysOn());
-        assertFalse(vpn.getLockdown());
-
-        // Set always-on with lockdown.
-        assertTrue(vpn.setAlwaysOnPackage(PKGS[1], true, Collections.emptyList()));
-        assertTrue(vpn.getAlwaysOn());
-        assertTrue(vpn.getLockdown());
-
-        // Remove always-on configuration.
-        assertTrue(vpn.setAlwaysOnPackage(null, false, Collections.emptyList()));
-        assertFalse(vpn.getAlwaysOn());
-        assertFalse(vpn.getLockdown());
-    }
-
-    @Test
-    public void testAlwaysOnWithoutLockdown() throws Exception {
-        final Vpn vpn = createVpn(PRIMARY_USER.id);
-        assertTrue(vpn.setAlwaysOnPackage(
-                PKGS[1], false /* lockdown */, null /* lockdownAllowlist */));
-        verify(mConnectivityManager, never()).setRequireVpnForUids(anyBoolean(), any());
-
-        assertTrue(vpn.setAlwaysOnPackage(
-                null /* packageName */, false /* lockdown */, null /* lockdownAllowlist */));
-        verify(mConnectivityManager, never()).setRequireVpnForUids(anyBoolean(), any());
-    }
-
-    @Test
-    public void testLockdownChangingPackage() throws Exception {
-        final Vpn vpn = createVpn(PRIMARY_USER.id);
-        final Range<Integer> user = PRIMARY_USER_RANGE;
-        final int userStart = user.getLower();
-        final int userStop = user.getUpper();
-        // Set always-on without lockdown.
-        assertTrue(vpn.setAlwaysOnPackage(PKGS[1], false, null));
-
-        // Set always-on with lockdown.
-        assertTrue(vpn.setAlwaysOnPackage(PKGS[1], true, null));
-        verify(mConnectivityManager).setRequireVpnForUids(true, toRanges(new UidRangeParcel[] {
-                new UidRangeParcel(userStart, userStart + PKG_UIDS[1] - 1),
-                new UidRangeParcel(userStart + PKG_UIDS[1] + 1,
-                                   Process.toSdkSandboxUid(userStart + PKG_UIDS[1] - 1)),
-                new UidRangeParcel(Process.toSdkSandboxUid(userStart + PKG_UIDS[1] + 1), userStop)
-        }));
-
-        // Switch to another app.
-        assertTrue(vpn.setAlwaysOnPackage(PKGS[3], true, null));
-        verify(mConnectivityManager).setRequireVpnForUids(false, toRanges(new UidRangeParcel[] {
-                new UidRangeParcel(userStart, userStart + PKG_UIDS[1] - 1),
-                new UidRangeParcel(userStart + PKG_UIDS[1] + 1,
-                                   Process.toSdkSandboxUid(userStart + PKG_UIDS[1] - 1)),
-                new UidRangeParcel(Process.toSdkSandboxUid(userStart + PKG_UIDS[1] + 1), userStop)
-        }));
-        verify(mConnectivityManager).setRequireVpnForUids(true, toRanges(new UidRangeParcel[] {
-                new UidRangeParcel(userStart, userStart + PKG_UIDS[3] - 1),
-                new UidRangeParcel(userStart + PKG_UIDS[3] + 1,
-                                   Process.toSdkSandboxUid(userStart + PKG_UIDS[3] - 1)),
-                new UidRangeParcel(Process.toSdkSandboxUid(userStart + PKG_UIDS[3] + 1), userStop)
-        }));
-    }
-
-    @Test
-    public void testLockdownAllowlist() throws Exception {
-        final Vpn vpn = createVpn(PRIMARY_USER.id);
-        final Range<Integer> user = PRIMARY_USER_RANGE;
-        final int userStart = user.getLower();
-        final int userStop = user.getUpper();
-        // Set always-on with lockdown and allow app PKGS[2] from lockdown.
-        assertTrue(vpn.setAlwaysOnPackage(
-                PKGS[1], true, Collections.singletonList(PKGS[2])));
-        verify(mConnectivityManager).setRequireVpnForUids(true, toRanges(new UidRangeParcel[]  {
-                new UidRangeParcel(userStart, userStart + PKG_UIDS[1] - 1),
-                new UidRangeParcel(userStart + PKG_UIDS[2] + 1,
-                                   Process.toSdkSandboxUid(userStart + PKG_UIDS[1]) - 1),
-                new UidRangeParcel(Process.toSdkSandboxUid(userStart + PKG_UIDS[2] + 1), userStop)
-        }));
-        // Change allowed app list to PKGS[3].
-        assertTrue(vpn.setAlwaysOnPackage(
-                PKGS[1], true, Collections.singletonList(PKGS[3])));
-        verify(mConnectivityManager).setRequireVpnForUids(false, toRanges(new UidRangeParcel[] {
-                new UidRangeParcel(userStart + PKG_UIDS[2] + 1,
-                                   Process.toSdkSandboxUid(userStart + PKG_UIDS[1] - 1)),
-                new UidRangeParcel(Process.toSdkSandboxUid(userStart + PKG_UIDS[2] + 1), userStop)
-        }));
-        verify(mConnectivityManager).setRequireVpnForUids(true, toRanges(new UidRangeParcel[] {
-                new UidRangeParcel(userStart + PKG_UIDS[1] + 1, userStart + PKG_UIDS[3] - 1),
-                new UidRangeParcel(userStart + PKG_UIDS[3] + 1,
-                                   Process.toSdkSandboxUid(userStart + PKG_UIDS[1] - 1)),
-                new UidRangeParcel(Process.toSdkSandboxUid(userStart + PKG_UIDS[1] + 1),
-                                   Process.toSdkSandboxUid(userStart + PKG_UIDS[3] - 1)),
-                new UidRangeParcel(Process.toSdkSandboxUid(userStart + PKG_UIDS[3] + 1), userStop)
-        }));
-
-        // Change the VPN app.
-        assertTrue(vpn.setAlwaysOnPackage(
-                PKGS[0], true, Collections.singletonList(PKGS[3])));
-        verify(mConnectivityManager).setRequireVpnForUids(false, toRanges(new UidRangeParcel[] {
-                new UidRangeParcel(userStart, userStart + PKG_UIDS[1] - 1),
-                new UidRangeParcel(userStart + PKG_UIDS[1] + 1, userStart + PKG_UIDS[3] - 1),
-                new UidRangeParcel(userStart + PKG_UIDS[3] + 1,
-                                   Process.toSdkSandboxUid(userStart + PKG_UIDS[1] - 1)),
-                new UidRangeParcel(Process.toSdkSandboxUid(userStart + PKG_UIDS[1] + 1),
-                                   Process.toSdkSandboxUid(userStart + PKG_UIDS[3] - 1))
-        }));
-        verify(mConnectivityManager).setRequireVpnForUids(true, toRanges(new UidRangeParcel[] {
-                new UidRangeParcel(userStart, userStart + PKG_UIDS[0] - 1),
-                new UidRangeParcel(userStart + PKG_UIDS[0] + 1, userStart + PKG_UIDS[3] - 1),
-                new UidRangeParcel(userStart + PKG_UIDS[3] + 1,
-                                   Process.toSdkSandboxUid(userStart + PKG_UIDS[0] - 1)),
-                new UidRangeParcel(Process.toSdkSandboxUid(userStart + PKG_UIDS[0] + 1),
-                                   Process.toSdkSandboxUid(userStart + PKG_UIDS[3] - 1))
-        }));
-
-        // Remove the list of allowed packages.
-        assertTrue(vpn.setAlwaysOnPackage(PKGS[0], true, null));
-        verify(mConnectivityManager).setRequireVpnForUids(false, toRanges(new UidRangeParcel[] {
-                new UidRangeParcel(userStart + PKG_UIDS[0] + 1, userStart + PKG_UIDS[3] - 1),
-                new UidRangeParcel(userStart + PKG_UIDS[3] + 1,
-                                   Process.toSdkSandboxUid(userStart + PKG_UIDS[0] - 1)),
-                new UidRangeParcel(Process.toSdkSandboxUid(userStart + PKG_UIDS[0] + 1),
-                                   Process.toSdkSandboxUid(userStart + PKG_UIDS[3] - 1)),
-                new UidRangeParcel(Process.toSdkSandboxUid(userStart + PKG_UIDS[3] + 1), userStop)
-        }));
-        verify(mConnectivityManager).setRequireVpnForUids(true, toRanges(new UidRangeParcel[] {
-                new UidRangeParcel(userStart + PKG_UIDS[0] + 1,
-                                   Process.toSdkSandboxUid(userStart + PKG_UIDS[0] - 1)),
-                new UidRangeParcel(Process.toSdkSandboxUid(userStart + PKG_UIDS[0] + 1), userStop),
-        }));
-
-        // Add the list of allowed packages.
-        assertTrue(vpn.setAlwaysOnPackage(
-                PKGS[0], true, Collections.singletonList(PKGS[1])));
-        verify(mConnectivityManager).setRequireVpnForUids(false, toRanges(new UidRangeParcel[] {
-                new UidRangeParcel(userStart + PKG_UIDS[0] + 1,
-                                   Process.toSdkSandboxUid(userStart + PKG_UIDS[0] - 1)),
-                new UidRangeParcel(Process.toSdkSandboxUid(userStart + PKG_UIDS[0] + 1), userStop),
-        }));
-        verify(mConnectivityManager).setRequireVpnForUids(true, toRanges(new UidRangeParcel[] {
-                new UidRangeParcel(userStart + PKG_UIDS[0] + 1, userStart + PKG_UIDS[1] - 1),
-                new UidRangeParcel(userStart + PKG_UIDS[1] + 1,
-                                   Process.toSdkSandboxUid(userStart + PKG_UIDS[0] - 1)),
-                new UidRangeParcel(Process.toSdkSandboxUid(userStart + PKG_UIDS[0] + 1),
-                                   Process.toSdkSandboxUid(userStart + PKG_UIDS[1] - 1)),
-                new UidRangeParcel(Process.toSdkSandboxUid(userStart + PKG_UIDS[1] + 1), userStop)
-        }));
-
-        // Try allowing a package with a comma, should be rejected.
-        assertFalse(vpn.setAlwaysOnPackage(
-                PKGS[0], true, Collections.singletonList("a.b,c.d")));
-
-        // Pass a non-existent packages in the allowlist, they (and only they) should be ignored.
-        // allowed package should change from PGKS[1] to PKGS[2].
-        assertTrue(vpn.setAlwaysOnPackage(
-                PKGS[0], true, Arrays.asList("com.foo.app", PKGS[2], "com.bar.app")));
-        verify(mConnectivityManager).setRequireVpnForUids(false, toRanges(new UidRangeParcel[] {
-                new UidRangeParcel(userStart + PKG_UIDS[0] + 1, userStart + PKG_UIDS[1] - 1),
-                new UidRangeParcel(userStart + PKG_UIDS[1] + 1,
-                                   Process.toSdkSandboxUid(userStart + PKG_UIDS[0] - 1)),
-                new UidRangeParcel(Process.toSdkSandboxUid(userStart + PKG_UIDS[0] + 1),
-                                   Process.toSdkSandboxUid(userStart + PKG_UIDS[1] - 1)),
-                new UidRangeParcel(Process.toSdkSandboxUid(userStart + PKG_UIDS[1] + 1), userStop)
-        }));
-        verify(mConnectivityManager).setRequireVpnForUids(true, toRanges(new UidRangeParcel[] {
-                new UidRangeParcel(userStart + PKG_UIDS[0] + 1, userStart + PKG_UIDS[2] - 1),
-                new UidRangeParcel(userStart + PKG_UIDS[2] + 1,
-                                   Process.toSdkSandboxUid(userStart + PKG_UIDS[0] - 1)),
-                new UidRangeParcel(Process.toSdkSandboxUid(userStart + PKG_UIDS[0] + 1),
-                                   Process.toSdkSandboxUid(userStart + PKG_UIDS[2] - 1)),
-                new UidRangeParcel(Process.toSdkSandboxUid(userStart + PKG_UIDS[2] + 1), userStop)
-        }));
-    }
-
-    @Test
-    public void testLockdownSystemUser() throws Exception {
-        final Vpn vpn = createVpn(SYSTEM_USER_ID);
-
-        // Uid 0 is always excluded and PKG_UIDS[1] is the uid of the VPN.
-        final List<Integer> excludedUids = new ArrayList<>(List.of(0, PKG_UIDS[1]));
-        final List<Range<Integer>> ranges = makeVpnUidRange(SYSTEM_USER_ID, excludedUids);
-
-        // Set always-on with lockdown.
-        assertTrue(vpn.setAlwaysOnPackage(
-                PKGS[1], true /* lockdown */, null /* lockdownAllowlist */));
-        verify(mConnectivityManager).setRequireVpnForUids(true, ranges);
-
-        // Disable always-on with lockdown.
-        assertTrue(vpn.setAlwaysOnPackage(
-                null /* packageName */, false /* lockdown */, null /* lockdownAllowlist */));
-        verify(mConnectivityManager).setRequireVpnForUids(false, ranges);
-
-        // Set always-on with lockdown and allow the app PKGS[2].
-        excludedUids.add(PKG_UIDS[2]);
-        final List<Range<Integer>> ranges2 = makeVpnUidRange(SYSTEM_USER_ID, excludedUids);
-        assertTrue(vpn.setAlwaysOnPackage(
-                PKGS[1], true /* lockdown */, Collections.singletonList(PKGS[2])));
-        verify(mConnectivityManager).setRequireVpnForUids(true, ranges2);
-
-        // Disable always-on with lockdown.
-        assertTrue(vpn.setAlwaysOnPackage(
-                null /* packageName */, false /* lockdown */, null /* lockdownAllowlist */));
-        verify(mConnectivityManager).setRequireVpnForUids(false, ranges2);
-    }
-
-    @Test
-    public void testLockdownRuleRepeatability() throws Exception {
-        final Vpn vpn = createVpn(PRIMARY_USER.id);
-        final UidRangeParcel[] primaryUserRangeParcel = new UidRangeParcel[] {
-                new UidRangeParcel(PRIMARY_USER_RANGE.getLower(), PRIMARY_USER_RANGE.getUpper())};
-        // Given legacy lockdown is already enabled,
-        vpn.setLockdown(true);
-        verify(mConnectivityManager, times(1)).setRequireVpnForUids(true,
-                toRanges(primaryUserRangeParcel));
-
-        // Enabling legacy lockdown twice should do nothing.
-        vpn.setLockdown(true);
-        verify(mConnectivityManager, times(1)).setRequireVpnForUids(anyBoolean(), any());
-
-        // And disabling should remove the rules exactly once.
-        vpn.setLockdown(false);
-        verify(mConnectivityManager, times(1)).setRequireVpnForUids(false,
-                toRanges(primaryUserRangeParcel));
-
-        // Removing the lockdown again should have no effect.
-        vpn.setLockdown(false);
-        verify(mConnectivityManager, times(2)).setRequireVpnForUids(anyBoolean(), any());
-    }
-
-    private ArrayList<Range<Integer>> toRanges(UidRangeParcel[] ranges) {
-        ArrayList<Range<Integer>> rangesArray = new ArrayList<>(ranges.length);
-        for (int i = 0; i < ranges.length; i++) {
-            rangesArray.add(new Range<>(ranges[i].start, ranges[i].stop));
-        }
-        return rangesArray;
-    }
-
-    @Test
-    public void testLockdownRuleReversibility() throws Exception {
-        doReturn(PERMISSION_GRANTED).when(mContext).checkCallingOrSelfPermission(CONTROL_VPN);
-        final Vpn vpn = createVpn(PRIMARY_USER.id);
-        final UidRangeParcel[] entireUser = {
-            new UidRangeParcel(PRIMARY_USER_RANGE.getLower(), PRIMARY_USER_RANGE.getUpper())
-        };
-        final UidRangeParcel[] exceptPkg0 = {
-            new UidRangeParcel(entireUser[0].start, entireUser[0].start + PKG_UIDS[0] - 1),
-            new UidRangeParcel(entireUser[0].start + PKG_UIDS[0] + 1,
-                               Process.toSdkSandboxUid(entireUser[0].start + PKG_UIDS[0] - 1)),
-            new UidRangeParcel(Process.toSdkSandboxUid(entireUser[0].start + PKG_UIDS[0] + 1),
-                               entireUser[0].stop),
-        };
-
-        final InOrder order = inOrder(mConnectivityManager);
-
-        // Given lockdown is enabled with no package (legacy VPN),
-        vpn.setLockdown(true);
-        order.verify(mConnectivityManager).setRequireVpnForUids(true, toRanges(entireUser));
-
-        // When a new VPN package is set the rules should change to cover that package.
-        vpn.prepare(null, PKGS[0], VpnManager.TYPE_VPN_SERVICE);
-        order.verify(mConnectivityManager).setRequireVpnForUids(false, toRanges(entireUser));
-        order.verify(mConnectivityManager).setRequireVpnForUids(true, toRanges(exceptPkg0));
-
-        // When that VPN package is unset, everything should be undone again in reverse.
-        vpn.prepare(null, VpnConfig.LEGACY_VPN, VpnManager.TYPE_VPN_SERVICE);
-        order.verify(mConnectivityManager).setRequireVpnForUids(false, toRanges(exceptPkg0));
-        order.verify(mConnectivityManager).setRequireVpnForUids(true, toRanges(entireUser));
-    }
-
-    @Test
-    public void testOnUserAddedAndRemoved_restrictedUser() throws Exception {
-        final InOrder order = inOrder(mMockNetworkAgent);
-        final Vpn vpn = createVpn(PRIMARY_USER.id);
-        final Set<Range<Integer>> initialRange = rangeSet(PRIMARY_USER_RANGE);
-        // Note since mVpnProfile is a Ikev2VpnProfile, this starts an IkeV2VpnRunner.
-        startLegacyVpn(vpn, mVpnProfile);
-        // Set an initial Uid range and mock the network agent
-        vpn.mNetworkCapabilities.setUids(initialRange);
-        vpn.mNetworkAgent = mMockNetworkAgent;
-
-        // Add the restricted user
-        setMockedUsers(PRIMARY_USER, RESTRICTED_PROFILE_A);
-        vpn.onUserAdded(RESTRICTED_PROFILE_A.id);
-        // Expect restricted user range to be added to the NetworkCapabilities.
-        final Set<Range<Integer>> expectRestrictedRange =
-                rangeSet(PRIMARY_USER_RANGE, uidRangeForUser(RESTRICTED_PROFILE_A.id));
-        assertEquals(expectRestrictedRange, vpn.mNetworkCapabilities.getUids());
-        order.verify(mMockNetworkAgent).doSendNetworkCapabilities(
-                argThat(nc -> expectRestrictedRange.equals(nc.getUids())));
-
-        // Remove the restricted user
-        vpn.onUserRemoved(RESTRICTED_PROFILE_A.id);
-        // Expect restricted user range to be removed from the NetworkCapabilities.
-        assertEquals(initialRange, vpn.mNetworkCapabilities.getUids());
-        order.verify(mMockNetworkAgent).doSendNetworkCapabilities(
-                argThat(nc -> initialRange.equals(nc.getUids())));
-    }
-
-    @Test
-    public void testOnUserAddedAndRemoved_restrictedUserLockdown() throws Exception {
-        final UidRangeParcel[] primaryUserRangeParcel = new UidRangeParcel[] {
-                new UidRangeParcel(PRIMARY_USER_RANGE.getLower(), PRIMARY_USER_RANGE.getUpper())};
-        final Range<Integer> restrictedUserRange = uidRangeForUser(RESTRICTED_PROFILE_A.id);
-        final UidRangeParcel[] restrictedUserRangeParcel = new UidRangeParcel[] {
-                new UidRangeParcel(restrictedUserRange.getLower(), restrictedUserRange.getUpper())};
-        final Vpn vpn = createVpn(PRIMARY_USER.id);
-
-        // Set lockdown calls setRequireVpnForUids
-        vpn.setLockdown(true);
-        verify(mConnectivityManager).setRequireVpnForUids(true, toRanges(primaryUserRangeParcel));
-
-        // Add the restricted user
-        doReturn(true).when(mUserManager).canHaveRestrictedProfile();
-        setMockedUsers(PRIMARY_USER, RESTRICTED_PROFILE_A);
-        vpn.onUserAdded(RESTRICTED_PROFILE_A.id);
-
-        // Expect restricted user range to be added.
-        verify(mConnectivityManager).setRequireVpnForUids(true,
-                toRanges(restrictedUserRangeParcel));
-
-        // Mark as partial indicates that the user is removed, mUserManager.getAliveUsers() does not
-        // return the restricted user but it is still returned in mUserManager.getUserInfo().
-        RESTRICTED_PROFILE_A.partial = true;
-        // Remove the restricted user
-        vpn.onUserRemoved(RESTRICTED_PROFILE_A.id);
-        verify(mConnectivityManager).setRequireVpnForUids(false,
-                toRanges(restrictedUserRangeParcel));
-        // reset to avoid affecting other tests since RESTRICTED_PROFILE_A is static.
-        RESTRICTED_PROFILE_A.partial = false;
-    }
-
-    @Test
-    public void testOnUserAddedAndRemoved_restrictedUserAlwaysOn() throws Exception {
-        final Vpn vpn = createVpn(PRIMARY_USER.id);
-
-        // setAlwaysOnPackage() calls setRequireVpnForUids()
-        assertTrue(vpn.setAlwaysOnPackage(
-                PKGS[0], true /* lockdown */, null /* lockdownAllowlist */));
-        final List<Integer> excludedUids = List.of(PKG_UIDS[0]);
-        final List<Range<Integer>> primaryRanges =
-                makeVpnUidRange(PRIMARY_USER.id, excludedUids);
-        verify(mConnectivityManager).setRequireVpnForUids(true, primaryRanges);
-
-        // Add the restricted user
-        doReturn(true).when(mUserManager).canHaveRestrictedProfile();
-        setMockedUsers(PRIMARY_USER, RESTRICTED_PROFILE_A);
-        vpn.onUserAdded(RESTRICTED_PROFILE_A.id);
-
-        final List<Range<Integer>> restrictedRanges =
-                makeVpnUidRange(RESTRICTED_PROFILE_A.id, excludedUids);
-        // Expect restricted user range to be added.
-        verify(mConnectivityManager).setRequireVpnForUids(true, restrictedRanges);
-
-        // Mark as partial indicates that the user is removed, mUserManager.getAliveUsers() does not
-        // return the restricted user but it is still returned in mUserManager.getUserInfo().
-        RESTRICTED_PROFILE_A.partial = true;
-        // Remove the restricted user
-        vpn.onUserRemoved(RESTRICTED_PROFILE_A.id);
-        verify(mConnectivityManager).setRequireVpnForUids(false, restrictedRanges);
-
-        // reset to avoid affecting other tests since RESTRICTED_PROFILE_A is static.
-        RESTRICTED_PROFILE_A.partial = false;
-    }
-
-    @Test
-    public void testPrepare_throwSecurityExceptionWhenGivenPackageDoesNotBelongToTheCaller()
-            throws Exception {
-        mTestDeps.mIgnoreCallingUidChecks = false;
-        final Vpn vpn = createVpn();
-        assertThrows(SecurityException.class,
-                () -> vpn.prepare("com.not.vpn.owner", null, VpnManager.TYPE_VPN_SERVICE));
-        assertThrows(SecurityException.class,
-                () -> vpn.prepare(null, "com.not.vpn.owner", VpnManager.TYPE_VPN_SERVICE));
-        assertThrows(SecurityException.class,
-                () -> vpn.prepare("com.not.vpn.owner1", "com.not.vpn.owner2",
-                        VpnManager.TYPE_VPN_SERVICE));
-    }
-
-    @Test
-    public void testPrepare_bothOldPackageAndNewPackageAreNull() throws Exception {
-        final Vpn vpn = createVpn();
-        assertTrue(vpn.prepare(null, null, VpnManager.TYPE_VPN_SERVICE));
-
-    }
-
-    @Test
-    public void testPrepare_legacyVpnWithoutControlVpn()
-            throws Exception {
-        doThrow(new SecurityException("no CONTROL_VPN")).when(mContext)
-                .enforceCallingOrSelfPermission(eq(CONTROL_VPN), any());
-        final Vpn vpn = createVpn();
-        assertThrows(SecurityException.class,
-                () -> vpn.prepare(null, VpnConfig.LEGACY_VPN, VpnManager.TYPE_VPN_SERVICE));
-
-        // CONTROL_VPN can be held by the caller or another system server process - both are
-        // allowed. Just checking for `enforceCallingPermission` may not be sufficient.
-        verify(mContext, never()).enforceCallingPermission(eq(CONTROL_VPN), any());
-    }
-
-    @Test
-    public void testPrepare_legacyVpnWithControlVpn()
-            throws Exception {
-        doNothing().when(mContext).enforceCallingOrSelfPermission(eq(CONTROL_VPN), any());
-        final Vpn vpn = createVpn();
-        assertTrue(vpn.prepare(null, VpnConfig.LEGACY_VPN, VpnManager.TYPE_VPN_SERVICE));
-
-        // CONTROL_VPN can be held by the caller or another system server process - both are
-        // allowed. Just checking for `enforceCallingPermission` may not be sufficient.
-        verify(mContext, never()).enforceCallingPermission(eq(CONTROL_VPN), any());
-    }
-
-    @Test
-    public void testIsAlwaysOnPackageSupported() throws Exception {
-        final Vpn vpn = createVpn(PRIMARY_USER.id);
-
-        ApplicationInfo appInfo = new ApplicationInfo();
-        when(mPackageManager.getApplicationInfoAsUser(eq(PKGS[0]), anyInt(), eq(PRIMARY_USER.id)))
-                .thenReturn(appInfo);
-
-        ServiceInfo svcInfo = new ServiceInfo();
-        ResolveInfo resInfo = new ResolveInfo();
-        resInfo.serviceInfo = svcInfo;
-        when(mPackageManager.queryIntentServicesAsUser(any(), eq(PackageManager.GET_META_DATA),
-                eq(PRIMARY_USER.id)))
-                .thenReturn(Collections.singletonList(resInfo));
-
-        // null package name should return false
-        assertFalse(vpn.isAlwaysOnPackageSupported(null));
-
-        // Pre-N apps are not supported
-        appInfo.targetSdkVersion = VERSION_CODES.M;
-        assertFalse(vpn.isAlwaysOnPackageSupported(PKGS[0]));
-
-        // N+ apps are supported by default
-        appInfo.targetSdkVersion = VERSION_CODES.N;
-        assertTrue(vpn.isAlwaysOnPackageSupported(PKGS[0]));
-
-        // Apps that opt out explicitly are not supported
-        appInfo.targetSdkVersion = VERSION_CODES.CUR_DEVELOPMENT;
-        Bundle metaData = new Bundle();
-        metaData.putBoolean(VpnService.SERVICE_META_DATA_SUPPORTS_ALWAYS_ON, false);
-        svcInfo.metaData = metaData;
-        assertFalse(vpn.isAlwaysOnPackageSupported(PKGS[0]));
-    }
-
-    @Test
-    public void testNotificationShownForAlwaysOnApp() throws Exception {
-        final UserHandle userHandle = UserHandle.of(PRIMARY_USER.id);
-        final Vpn vpn = createVpn(PRIMARY_USER.id);
-        setMockedUsers(PRIMARY_USER);
-
-        final InOrder order = inOrder(mNotificationManager);
-
-        // Don't show a notification for regular disconnected states.
-        vpn.updateState(DetailedState.DISCONNECTED, TAG);
-        order.verify(mNotificationManager, atLeastOnce()).cancel(anyString(), anyInt());
-
-        // Start showing a notification for disconnected once always-on.
-        vpn.setAlwaysOnPackage(PKGS[0], false, null);
-        order.verify(mNotificationManager).notify(anyString(), anyInt(), any());
-
-        // Stop showing the notification once connected.
-        vpn.updateState(DetailedState.CONNECTED, TAG);
-        order.verify(mNotificationManager).cancel(anyString(), anyInt());
-
-        // Show the notification if we disconnect again.
-        vpn.updateState(DetailedState.DISCONNECTED, TAG);
-        order.verify(mNotificationManager).notify(anyString(), anyInt(), any());
-
-        // Notification should be cleared after unsetting always-on package.
-        vpn.setAlwaysOnPackage(null, false, null);
-        order.verify(mNotificationManager).cancel(anyString(), anyInt());
-    }
-
-    /**
-     * The profile name should NOT change between releases for backwards compatibility
-     *
-     * <p>If this is changed between releases, the {@link Vpn#getVpnProfilePrivileged()} method MUST
-     * be updated to ensure backward compatibility.
-     */
-    @Test
-    public void testGetProfileNameForPackage() throws Exception {
-        final Vpn vpn = createVpn(PRIMARY_USER.id);
-        setMockedUsers(PRIMARY_USER);
-
-        final String expected = Credentials.PLATFORM_VPN + PRIMARY_USER.id + "_" + TEST_VPN_PKG;
-        assertEquals(expected, vpn.getProfileNameForPackage(TEST_VPN_PKG));
-    }
-
-    private Vpn createVpn(String... grantedOps) throws Exception {
-        return createVpn(PRIMARY_USER, grantedOps);
-    }
-
-    private Vpn createVpn(UserInfo user, String... grantedOps) throws Exception {
-        final Vpn vpn = createVpn(user.id);
-        setMockedUsers(user);
-
-        for (final String opStr : grantedOps) {
-            when(mAppOps.noteOpNoThrow(opStr, Process.myUid(), TEST_VPN_PKG,
-                    null /* attributionTag */, null /* message */))
-                    .thenReturn(AppOpsManager.MODE_ALLOWED);
-        }
-
-        return vpn;
-    }
-
-    private void checkProvisionVpnProfile(Vpn vpn, boolean expectedResult, String... checkedOps) {
-        assertEquals(expectedResult, vpn.provisionVpnProfile(TEST_VPN_PKG, mVpnProfile));
-
-        // The profile should always be stored, whether or not consent has been previously granted.
-        verify(mVpnProfileStore)
-                .put(
-                        eq(vpn.getProfileNameForPackage(TEST_VPN_PKG)),
-                        eq(mVpnProfile.encode()));
-
-        for (final String checkedOpStr : checkedOps) {
-            verify(mAppOps).noteOpNoThrow(checkedOpStr, Process.myUid(), TEST_VPN_PKG,
-                    null /* attributionTag */, null /* message */);
-        }
-    }
-
-    @Test
-    public void testProvisionVpnProfileNoIpsecTunnels() throws Exception {
-        when(mPackageManager.hasSystemFeature(PackageManager.FEATURE_IPSEC_TUNNELS))
-                .thenReturn(false);
-        final Vpn vpn = createVpn(AppOpsManager.OPSTR_ACTIVATE_PLATFORM_VPN);
-
-        try {
-            checkProvisionVpnProfile(
-                    vpn, true /* expectedResult */, AppOpsManager.OPSTR_ACTIVATE_PLATFORM_VPN);
-            fail("Expected exception due to missing feature");
-        } catch (UnsupportedOperationException expected) {
-        }
-    }
-
-    private String startVpnForVerifyAppExclusionList(Vpn vpn) throws Exception {
-        when(mVpnProfileStore.get(vpn.getProfileNameForPackage(TEST_VPN_PKG)))
-                .thenReturn(mVpnProfile.encode());
-        when(mVpnProfileStore.get(PRIMARY_USER_APP_EXCLUDE_KEY))
-                .thenReturn(HexDump.hexStringToByteArray(PKGS_BYTES));
-        final String sessionKey = vpn.startVpnProfile(TEST_VPN_PKG);
-        final Set<Range<Integer>> uidRanges = vpn.createUserAndRestrictedProfilesRanges(
-                PRIMARY_USER.id, null /* allowedApplications */, Arrays.asList(PKGS));
-        verify(mConnectivityManager).setVpnDefaultForUids(eq(sessionKey), eq(uidRanges));
-        clearInvocations(mConnectivityManager);
-        verify(mVpnProfileStore).get(eq(vpn.getProfileNameForPackage(TEST_VPN_PKG)));
-        vpn.mNetworkAgent = mMockNetworkAgent;
-
-        return sessionKey;
-    }
-
-    private Vpn prepareVpnForVerifyAppExclusionList() throws Exception {
-        final Vpn vpn = createVpn(AppOpsManager.OPSTR_ACTIVATE_PLATFORM_VPN);
-        startVpnForVerifyAppExclusionList(vpn);
-
-        return vpn;
-    }
-
-    @Test
-    public void testSetAndGetAppExclusionList() throws Exception {
-        final Vpn vpn = createVpn(AppOpsManager.OPSTR_ACTIVATE_PLATFORM_VPN);
-        final String sessionKey = startVpnForVerifyAppExclusionList(vpn);
-        verify(mVpnProfileStore, never()).put(eq(PRIMARY_USER_APP_EXCLUDE_KEY), any());
-        vpn.setAppExclusionList(TEST_VPN_PKG, Arrays.asList(PKGS));
-        verify(mVpnProfileStore)
-                .put(eq(PRIMARY_USER_APP_EXCLUDE_KEY),
-                     eq(HexDump.hexStringToByteArray(PKGS_BYTES)));
-        final Set<Range<Integer>> uidRanges = vpn.createUserAndRestrictedProfilesRanges(
-                PRIMARY_USER.id, null /* allowedApplications */, Arrays.asList(PKGS));
-        verify(mConnectivityManager).setVpnDefaultForUids(eq(sessionKey), eq(uidRanges));
-        assertEquals(uidRanges, vpn.mNetworkCapabilities.getUids());
-        assertEquals(Arrays.asList(PKGS), vpn.getAppExclusionList(TEST_VPN_PKG));
-    }
-
-    @Test
-    public void testRefreshPlatformVpnAppExclusionList_updatesExcludedUids() throws Exception {
-        final Vpn vpn = createVpn(AppOpsManager.OPSTR_ACTIVATE_PLATFORM_VPN);
-        final String sessionKey = startVpnForVerifyAppExclusionList(vpn);
-        vpn.setAppExclusionList(TEST_VPN_PKG, Arrays.asList(PKGS));
-        final Set<Range<Integer>> uidRanges = vpn.createUserAndRestrictedProfilesRanges(
-                PRIMARY_USER.id, null /* allowedApplications */, Arrays.asList(PKGS));
-        verify(mConnectivityManager).setVpnDefaultForUids(eq(sessionKey), eq(uidRanges));
-        verify(mMockNetworkAgent).doSendNetworkCapabilities(any());
-        assertEquals(Arrays.asList(PKGS), vpn.getAppExclusionList(TEST_VPN_PKG));
-
-        reset(mMockNetworkAgent);
-
-        // Remove one of the package
-        List<Integer> newExcludedUids = toList(PKG_UIDS);
-        newExcludedUids.remove((Integer) PKG_UIDS[0]);
-        Set<Range<Integer>> newUidRanges = makeVpnUidRangeSet(PRIMARY_USER.id, newExcludedUids);
-        sPackages.remove(PKGS[0]);
-        vpn.refreshPlatformVpnAppExclusionList();
-
-        // List in keystore is not changed, but UID for the removed packages is no longer exempted.
-        assertEquals(Arrays.asList(PKGS), vpn.getAppExclusionList(TEST_VPN_PKG));
-        assertEquals(newUidRanges, vpn.mNetworkCapabilities.getUids());
-        ArgumentCaptor<NetworkCapabilities> ncCaptor =
-                ArgumentCaptor.forClass(NetworkCapabilities.class);
-        verify(mMockNetworkAgent).doSendNetworkCapabilities(ncCaptor.capture());
-        assertEquals(newUidRanges, ncCaptor.getValue().getUids());
-        verify(mConnectivityManager).setVpnDefaultForUids(eq(sessionKey), eq(newUidRanges));
-
-        reset(mMockNetworkAgent);
-
-        // Add the package back
-        newExcludedUids.add(PKG_UIDS[0]);
-        newUidRanges = makeVpnUidRangeSet(PRIMARY_USER.id, newExcludedUids);
-        sPackages.put(PKGS[0], PKG_UIDS[0]);
-        vpn.refreshPlatformVpnAppExclusionList();
-
-        // List in keystore is not changed and the uid list should be updated in the net cap.
-        assertEquals(Arrays.asList(PKGS), vpn.getAppExclusionList(TEST_VPN_PKG));
-        assertEquals(newUidRanges, vpn.mNetworkCapabilities.getUids());
-        verify(mMockNetworkAgent).doSendNetworkCapabilities(ncCaptor.capture());
-        assertEquals(newUidRanges, ncCaptor.getValue().getUids());
-
-        // The uidRange is the same as the original setAppExclusionList so this is the second call
-        verify(mConnectivityManager, times(2))
-                .setVpnDefaultForUids(eq(sessionKey), eq(newUidRanges));
-    }
-
-    private List<Range<Integer>> makeVpnUidRange(int userId, List<Integer> excludedAppIdList) {
-        final SortedSet<Integer> list = new TreeSet<>();
-
-        final int userBase = userId * UserHandle.PER_USER_RANGE;
-        for (int appId : excludedAppIdList) {
-            final int uid = UserHandle.getUid(userId, appId);
-            list.add(uid);
-            if (Process.isApplicationUid(uid)) {
-                list.add(Process.toSdkSandboxUid(uid)); // Add Sdk Sandbox UID
-            }
-        }
-
-        final int minUid = userBase;
-        final int maxUid = userBase + UserHandle.PER_USER_RANGE - 1;
-        final List<Range<Integer>> ranges = new ArrayList<>();
-
-        // Iterate the list to create the ranges between each uid.
-        int start = minUid;
-        for (int uid : list) {
-            if (uid == start) {
-                start++;
-            } else {
-                ranges.add(new Range<>(start, uid - 1));
-                start = uid + 1;
-            }
-        }
-
-        // Create the range between last uid and max uid.
-        if (start <= maxUid) {
-            ranges.add(new Range<>(start, maxUid));
-        }
-
-        return ranges;
-    }
-
-    private Set<Range<Integer>> makeVpnUidRangeSet(int userId, List<Integer> excludedAppIdList) {
-        return new ArraySet<>(makeVpnUidRange(userId, excludedAppIdList));
-    }
-
-    @Test
-    public void testSetAndGetAppExclusionListRestrictedUser() throws Exception {
-        final Vpn vpn = prepareVpnForVerifyAppExclusionList();
-
-        // Mock it to restricted profile
-        when(mUserManager.getUserInfo(anyInt())).thenReturn(RESTRICTED_PROFILE_A);
-
-        // Restricted users cannot configure VPNs
-        assertThrows(SecurityException.class,
-                () -> vpn.setAppExclusionList(TEST_VPN_PKG, new ArrayList<>()));
-
-        assertEquals(Arrays.asList(PKGS), vpn.getAppExclusionList(TEST_VPN_PKG));
-    }
-
-    @Test
-    public void testProvisionVpnProfilePreconsented() throws Exception {
-        final Vpn vpn = createVpn(AppOpsManager.OPSTR_ACTIVATE_PLATFORM_VPN);
-
-        checkProvisionVpnProfile(
-                vpn, true /* expectedResult */, AppOpsManager.OPSTR_ACTIVATE_PLATFORM_VPN);
-    }
-
-    @Test
-    public void testProvisionVpnProfileNotPreconsented() throws Exception {
-        final Vpn vpn = createVpn();
-
-        // Expect that both the ACTIVATE_VPN and ACTIVATE_PLATFORM_VPN were tried, but the caller
-        // had neither.
-        checkProvisionVpnProfile(vpn, false /* expectedResult */,
-                AppOpsManager.OPSTR_ACTIVATE_PLATFORM_VPN, AppOpsManager.OPSTR_ACTIVATE_VPN);
-    }
-
-    @Test
-    public void testProvisionVpnProfileVpnServicePreconsented() throws Exception {
-        final Vpn vpn = createVpn(AppOpsManager.OPSTR_ACTIVATE_VPN);
-
-        checkProvisionVpnProfile(vpn, true /* expectedResult */, AppOpsManager.OPSTR_ACTIVATE_VPN);
-    }
-
-    @Test
-    public void testProvisionVpnProfileTooLarge() throws Exception {
-        final Vpn vpn = createVpn(AppOpsManager.OPSTR_ACTIVATE_PLATFORM_VPN);
-
-        final VpnProfile bigProfile = new VpnProfile("");
-        bigProfile.name = new String(new byte[Vpn.MAX_VPN_PROFILE_SIZE_BYTES + 1]);
-
-        try {
-            vpn.provisionVpnProfile(TEST_VPN_PKG, bigProfile);
-            fail("Expected IAE due to profile size");
-        } catch (IllegalArgumentException expected) {
-        }
-    }
-
-    @Test
-    public void testProvisionVpnProfileRestrictedUser() throws Exception {
-        final Vpn vpn =
-                createVpn(
-                        RESTRICTED_PROFILE_A, AppOpsManager.OPSTR_ACTIVATE_PLATFORM_VPN);
-
-        try {
-            vpn.provisionVpnProfile(TEST_VPN_PKG, mVpnProfile);
-            fail("Expected SecurityException due to restricted user");
-        } catch (SecurityException expected) {
-        }
-    }
-
-    @Test
-    public void testDeleteVpnProfile() throws Exception {
-        final Vpn vpn = createVpn();
-
-        vpn.deleteVpnProfile(TEST_VPN_PKG);
-
-        verify(mVpnProfileStore)
-                .remove(eq(vpn.getProfileNameForPackage(TEST_VPN_PKG)));
-    }
-
-    @Test
-    public void testDeleteVpnProfileRestrictedUser() throws Exception {
-        final Vpn vpn =
-                createVpn(
-                        RESTRICTED_PROFILE_A, AppOpsManager.OPSTR_ACTIVATE_PLATFORM_VPN);
-
-        try {
-            vpn.deleteVpnProfile(TEST_VPN_PKG);
-            fail("Expected SecurityException due to restricted user");
-        } catch (SecurityException expected) {
-        }
-    }
-
-    @Test
-    public void testGetVpnProfilePrivileged() throws Exception {
-        final Vpn vpn = createVpn();
-
-        when(mVpnProfileStore.get(vpn.getProfileNameForPackage(TEST_VPN_PKG)))
-                .thenReturn(new VpnProfile("").encode());
-
-        vpn.getVpnProfilePrivileged(TEST_VPN_PKG);
-
-        verify(mVpnProfileStore).get(eq(vpn.getProfileNameForPackage(TEST_VPN_PKG)));
-    }
-
-    private void verifyPlatformVpnIsActivated(String packageName) {
-        verify(mAppOps).noteOpNoThrow(
-                eq(AppOpsManager.OPSTR_ACTIVATE_PLATFORM_VPN),
-                eq(Process.myUid()),
-                eq(packageName),
-                eq(null) /* attributionTag */,
-                eq(null) /* message */);
-        verify(mAppOps).startOp(
-                eq(AppOpsManager.OPSTR_ESTABLISH_VPN_MANAGER),
-                eq(UserHandle.getUid(PRIMARY_USER.id, Process.myUid())),
-                eq(packageName),
-                eq(null) /* attributionTag */,
-                eq(null) /* message */);
-    }
-
-    private void verifyPlatformVpnIsDeactivated(String packageName) {
-        // Add a small delay to double confirm that finishOp is only called once.
-        verify(mAppOps, after(100)).finishOp(
-                eq(AppOpsManager.OPSTR_ESTABLISH_VPN_MANAGER),
-                eq(UserHandle.getUid(PRIMARY_USER.id, Process.myUid())),
-                eq(packageName),
-                eq(null) /* attributionTag */);
-    }
-
-    @Test
-    public void testStartVpnProfile() throws Exception {
-        final Vpn vpn = createVpn(AppOpsManager.OPSTR_ACTIVATE_PLATFORM_VPN);
-
-        when(mVpnProfileStore.get(vpn.getProfileNameForPackage(TEST_VPN_PKG)))
-                .thenReturn(mVpnProfile.encode());
-
-        vpn.startVpnProfile(TEST_VPN_PKG);
-
-        verify(mVpnProfileStore).get(eq(vpn.getProfileNameForPackage(TEST_VPN_PKG)));
-        verifyPlatformVpnIsActivated(TEST_VPN_PKG);
-    }
-
-    @Test
-    public void testStartVpnProfileVpnServicePreconsented() throws Exception {
-        final Vpn vpn = createVpn(AppOpsManager.OPSTR_ACTIVATE_VPN);
-
-        when(mVpnProfileStore.get(vpn.getProfileNameForPackage(TEST_VPN_PKG)))
-                .thenReturn(mVpnProfile.encode());
-
-        vpn.startVpnProfile(TEST_VPN_PKG);
-
-        // Verify that the ACTIVATE_VPN appop was checked, but no error was thrown.
-        verify(mAppOps).noteOpNoThrow(AppOpsManager.OPSTR_ACTIVATE_VPN, Process.myUid(),
-                TEST_VPN_PKG, null /* attributionTag */, null /* message */);
-    }
-
-    @Test
-    public void testStartVpnProfileNotConsented() throws Exception {
-        final Vpn vpn = createVpn();
-
-        try {
-            vpn.startVpnProfile(TEST_VPN_PKG);
-            fail("Expected failure due to no user consent");
-        } catch (SecurityException expected) {
-        }
-
-        // Verify both appops were checked.
-        verify(mAppOps)
-                .noteOpNoThrow(
-                        eq(AppOpsManager.OPSTR_ACTIVATE_PLATFORM_VPN),
-                        eq(Process.myUid()),
-                        eq(TEST_VPN_PKG),
-                        eq(null) /* attributionTag */,
-                        eq(null) /* message */);
-        verify(mAppOps).noteOpNoThrow(AppOpsManager.OPSTR_ACTIVATE_VPN, Process.myUid(),
-                TEST_VPN_PKG, null /* attributionTag */, null /* message */);
-
-        // Keystore should never have been accessed.
-        verify(mVpnProfileStore, never()).get(any());
-    }
-
-    @Test
-    public void testStartVpnProfileMissingProfile() throws Exception {
-        final Vpn vpn = createVpn(AppOpsManager.OPSTR_ACTIVATE_PLATFORM_VPN);
-
-        when(mVpnProfileStore.get(vpn.getProfileNameForPackage(TEST_VPN_PKG))).thenReturn(null);
-
-        try {
-            vpn.startVpnProfile(TEST_VPN_PKG);
-            fail("Expected failure due to missing profile");
-        } catch (IllegalArgumentException expected) {
-        }
-
-        verify(mVpnProfileStore).get(vpn.getProfileNameForPackage(TEST_VPN_PKG));
-        verify(mAppOps)
-                .noteOpNoThrow(
-                        eq(AppOpsManager.OPSTR_ACTIVATE_PLATFORM_VPN),
-                        eq(Process.myUid()),
-                        eq(TEST_VPN_PKG),
-                        eq(null) /* attributionTag */,
-                        eq(null) /* message */);
-    }
-
-    @Test
-    public void testStartVpnProfileRestrictedUser() throws Exception {
-        final Vpn vpn = createVpn(RESTRICTED_PROFILE_A, AppOpsManager.OPSTR_ACTIVATE_PLATFORM_VPN);
-
-        try {
-            vpn.startVpnProfile(TEST_VPN_PKG);
-            fail("Expected SecurityException due to restricted user");
-        } catch (SecurityException expected) {
-        }
-    }
-
-    @Test
-    public void testStopVpnProfileRestrictedUser() throws Exception {
-        final Vpn vpn = createVpn(RESTRICTED_PROFILE_A, AppOpsManager.OPSTR_ACTIVATE_PLATFORM_VPN);
-
-        try {
-            vpn.stopVpnProfile(TEST_VPN_PKG);
-            fail("Expected SecurityException due to restricted user");
-        } catch (SecurityException expected) {
-        }
-    }
-
-    @Test
-    public void testStartOpAndFinishOpWillBeCalledWhenPlatformVpnIsOnAndOff() throws Exception {
-        final Vpn vpn = createVpn(AppOpsManager.OPSTR_ACTIVATE_PLATFORM_VPN);
-        when(mVpnProfileStore.get(vpn.getProfileNameForPackage(TEST_VPN_PKG)))
-                .thenReturn(mVpnProfile.encode());
-        vpn.startVpnProfile(TEST_VPN_PKG);
-        verifyPlatformVpnIsActivated(TEST_VPN_PKG);
-        // Add a small delay to make sure that startOp is only called once.
-        verify(mAppOps, after(100).times(1)).startOp(
-                eq(AppOpsManager.OPSTR_ESTABLISH_VPN_MANAGER),
-                eq(UserHandle.getUid(PRIMARY_USER.id, Process.myUid())),
-                eq(TEST_VPN_PKG),
-                eq(null) /* attributionTag */,
-                eq(null) /* message */);
-        // Check that the startOp is not called with OPSTR_ESTABLISH_VPN_SERVICE.
-        verify(mAppOps, never()).startOp(
-                eq(AppOpsManager.OPSTR_ESTABLISH_VPN_SERVICE),
-                eq(UserHandle.getUid(PRIMARY_USER.id, Process.myUid())),
-                eq(TEST_VPN_PKG),
-                eq(null) /* attributionTag */,
-                eq(null) /* message */);
-        vpn.stopVpnProfile(TEST_VPN_PKG);
-        verifyPlatformVpnIsDeactivated(TEST_VPN_PKG);
-    }
-
-    @Test
-    public void testStartOpWithSeamlessHandover() throws Exception {
-        // Create with SYSTEM_USER so that establish() will match the user ID when checking
-        // against Binder.getCallerUid
-        final Vpn vpn = createVpn(SYSTEM_USER, AppOpsManager.OPSTR_ACTIVATE_VPN);
-        assertTrue(vpn.prepare(TEST_VPN_PKG, null, VpnManager.TYPE_VPN_SERVICE));
-        final VpnConfig config = new VpnConfig();
-        config.user = "VpnTest";
-        config.addresses.add(new LinkAddress("192.0.2.2/32"));
-        config.mtu = 1450;
-        final ResolveInfo resolveInfo = new ResolveInfo();
-        final ServiceInfo serviceInfo = new ServiceInfo();
-        serviceInfo.permission = BIND_VPN_SERVICE;
-        resolveInfo.serviceInfo = serviceInfo;
-        when(mPackageManager.resolveService(any(), anyInt())).thenReturn(resolveInfo);
-        when(mContext.bindServiceAsUser(any(), any(), anyInt(), any())).thenReturn(true);
-        vpn.establish(config);
-        verify(mAppOps, times(1)).startOp(
-                eq(AppOpsManager.OPSTR_ESTABLISH_VPN_SERVICE),
-                eq(Process.myUid()),
-                eq(TEST_VPN_PKG),
-                eq(null) /* attributionTag */,
-                eq(null) /* message */);
-        // Call establish() twice with the same config, it should match seamless handover case and
-        // startOp() shouldn't be called again.
-        vpn.establish(config);
-        verify(mAppOps, times(1)).startOp(
-                eq(AppOpsManager.OPSTR_ESTABLISH_VPN_SERVICE),
-                eq(Process.myUid()),
-                eq(TEST_VPN_PKG),
-                eq(null) /* attributionTag */,
-                eq(null) /* message */);
-    }
-
-    private void verifyVpnManagerEvent(String sessionKey, String category, int errorClass,
-            int errorCode, String[] packageName, @NonNull VpnProfileState... profileState) {
-        final Context userContext =
-                mContext.createContextAsUser(UserHandle.of(PRIMARY_USER.id), 0 /* flags */);
-        final ArgumentCaptor<Intent> intentArgumentCaptor = ArgumentCaptor.forClass(Intent.class);
-
-        final int verifyTimes = profileState.length;
-        verify(userContext, timeout(TEST_TIMEOUT_MS).times(verifyTimes))
-                .startService(intentArgumentCaptor.capture());
-
-        for (int i = 0; i < verifyTimes; i++) {
-            final Intent intent = intentArgumentCaptor.getAllValues().get(i);
-            assertEquals(packageName[i], intent.getPackage());
-            assertEquals(sessionKey, intent.getStringExtra(VpnManager.EXTRA_SESSION_KEY));
-            final Set<String> categories = intent.getCategories();
-            assertTrue(categories.contains(category));
-            assertEquals(1, categories.size());
-            assertEquals(errorClass,
-                    intent.getIntExtra(VpnManager.EXTRA_ERROR_CLASS, -1 /* defaultValue */));
-            assertEquals(errorCode,
-                    intent.getIntExtra(VpnManager.EXTRA_ERROR_CODE, -1 /* defaultValue */));
-            // CATEGORY_EVENT_DEACTIVATED_BY_USER & CATEGORY_EVENT_ALWAYS_ON_STATE_CHANGED won't
-            // send NetworkCapabilities & LinkProperties to VPN app.
-            // For ERROR_CODE_NETWORK_LOST, the NetworkCapabilities & LinkProperties of underlying
-            // network will be cleared. So the VPN app will receive null for those 2 extra values.
-            if (category.equals(VpnManager.CATEGORY_EVENT_DEACTIVATED_BY_USER)
-                    || category.equals(VpnManager.CATEGORY_EVENT_ALWAYS_ON_STATE_CHANGED)
-                    || errorCode == VpnManager.ERROR_CODE_NETWORK_LOST) {
-                assertNull(intent.getParcelableExtra(
-                        VpnManager.EXTRA_UNDERLYING_NETWORK_CAPABILITIES));
-                assertNull(intent.getParcelableExtra(VpnManager.EXTRA_UNDERLYING_LINK_PROPERTIES));
-            } else {
-                assertNotNull(intent.getParcelableExtra(
-                        VpnManager.EXTRA_UNDERLYING_NETWORK_CAPABILITIES));
-                assertNotNull(intent.getParcelableExtra(
-                        VpnManager.EXTRA_UNDERLYING_LINK_PROPERTIES));
-            }
-
-            assertEquals(profileState[i], intent.getParcelableExtra(
-                    VpnManager.EXTRA_VPN_PROFILE_STATE, VpnProfileState.class));
-        }
-        reset(userContext);
-    }
-
-    private void verifyDeactivatedByUser(String sessionKey, String[] packageName) {
-        // CATEGORY_EVENT_DEACTIVATED_BY_USER is not an error event, so both of errorClass and
-        // errorCode won't be set.
-        verifyVpnManagerEvent(sessionKey, VpnManager.CATEGORY_EVENT_DEACTIVATED_BY_USER,
-                -1 /* errorClass */, -1 /* errorCode */, packageName,
-                // VPN NetworkAgnet does not switch to CONNECTED in the test, and the state is not
-                // important here. Verify that the state as it is, i.e. CONNECTING state.
-                new VpnProfileState(VpnProfileState.STATE_CONNECTING,
-                        sessionKey, false /* alwaysOn */, false /* lockdown */));
-    }
-
-    private void verifyAlwaysOnStateChanged(String[] packageName, VpnProfileState... profileState) {
-        verifyVpnManagerEvent(null /* sessionKey */,
-                VpnManager.CATEGORY_EVENT_ALWAYS_ON_STATE_CHANGED, -1 /* errorClass */,
-                -1 /* errorCode */, packageName, profileState);
-    }
-
-    @Test
-    public void testVpnManagerEventForUserDeactivated() throws Exception {
-        // For security reasons, Vpn#prepare() will check that oldPackage and newPackage are either
-        // null or the package of the caller. This test will call Vpn#prepare() to pretend the old
-        // VPN is replaced by a new one. But only Settings can change to some other packages, and
-        // this is checked with CONTROL_VPN so simulate holding CONTROL_VPN in order to pass the
-        // security checks.
-        doReturn(PERMISSION_GRANTED).when(mContext).checkCallingOrSelfPermission(CONTROL_VPN);
-        final Vpn vpn = createVpn(AppOpsManager.OPSTR_ACTIVATE_PLATFORM_VPN);
-        when(mVpnProfileStore.get(vpn.getProfileNameForPackage(TEST_VPN_PKG)))
-                .thenReturn(mVpnProfile.encode());
-
-        // Test the case that the user deactivates the vpn in vpn app.
-        final String sessionKey1 = vpn.startVpnProfile(TEST_VPN_PKG);
-        verifyPlatformVpnIsActivated(TEST_VPN_PKG);
-        vpn.stopVpnProfile(TEST_VPN_PKG);
-        verifyPlatformVpnIsDeactivated(TEST_VPN_PKG);
-        verifyPowerSaveTempWhitelistApp(TEST_VPN_PKG);
-        reset(mDeviceIdleInternal);
-        verifyDeactivatedByUser(sessionKey1, new String[] {TEST_VPN_PKG});
-        reset(mAppOps);
-
-        // Test the case that the user chooses another vpn and the original one is replaced.
-        final String sessionKey2 = vpn.startVpnProfile(TEST_VPN_PKG);
-        verifyPlatformVpnIsActivated(TEST_VPN_PKG);
-        vpn.prepare(TEST_VPN_PKG, "com.new.vpn" /* newPackage */, TYPE_VPN_PLATFORM);
-        verifyPlatformVpnIsDeactivated(TEST_VPN_PKG);
-        verifyPowerSaveTempWhitelistApp(TEST_VPN_PKG);
-        reset(mDeviceIdleInternal);
-        verifyDeactivatedByUser(sessionKey2, new String[] {TEST_VPN_PKG});
-    }
-
-    @Test
-    public void testVpnManagerEventForAlwaysOnChanged() throws Exception {
-        // Calling setAlwaysOnPackage() needs to hold CONTROL_VPN.
-        doReturn(PERMISSION_GRANTED).when(mContext).checkCallingOrSelfPermission(CONTROL_VPN);
-        final Vpn vpn = createVpn(PRIMARY_USER.id);
-        // Enable VPN always-on for PKGS[1].
-        assertTrue(vpn.setAlwaysOnPackage(PKGS[1], false /* lockdown */,
-                null /* lockdownAllowlist */));
-        verifyPowerSaveTempWhitelistApp(PKGS[1]);
-        reset(mDeviceIdleInternal);
-        verifyAlwaysOnStateChanged(new String[] {PKGS[1]},
-                new VpnProfileState(VpnProfileState.STATE_DISCONNECTED,
-                        null /* sessionKey */, true /* alwaysOn */, false /* lockdown */));
-
-        // Enable VPN lockdown for PKGS[1].
-        assertTrue(vpn.setAlwaysOnPackage(PKGS[1], true /* lockdown */,
-                null /* lockdownAllowlist */));
-        verifyPowerSaveTempWhitelistApp(PKGS[1]);
-        reset(mDeviceIdleInternal);
-        verifyAlwaysOnStateChanged(new String[] {PKGS[1]},
-                new VpnProfileState(VpnProfileState.STATE_DISCONNECTED,
-                        null /* sessionKey */, true /* alwaysOn */, true /* lockdown */));
-
-        // Disable VPN lockdown for PKGS[1].
-        assertTrue(vpn.setAlwaysOnPackage(PKGS[1], false /* lockdown */,
-                null /* lockdownAllowlist */));
-        verifyPowerSaveTempWhitelistApp(PKGS[1]);
-        reset(mDeviceIdleInternal);
-        verifyAlwaysOnStateChanged(new String[] {PKGS[1]},
-                new VpnProfileState(VpnProfileState.STATE_DISCONNECTED,
-                        null /* sessionKey */, true /* alwaysOn */, false /* lockdown */));
-
-        // Disable VPN always-on.
-        assertTrue(vpn.setAlwaysOnPackage(null, false /* lockdown */,
-                null /* lockdownAllowlist */));
-        verifyPowerSaveTempWhitelistApp(PKGS[1]);
-        reset(mDeviceIdleInternal);
-        verifyAlwaysOnStateChanged(new String[] {PKGS[1]},
-                new VpnProfileState(VpnProfileState.STATE_DISCONNECTED,
-                        null /* sessionKey */, false /* alwaysOn */, false /* lockdown */));
-
-        // Enable VPN always-on for PKGS[1] again.
-        assertTrue(vpn.setAlwaysOnPackage(PKGS[1], false /* lockdown */,
-                null /* lockdownAllowlist */));
-        verifyPowerSaveTempWhitelistApp(PKGS[1]);
-        reset(mDeviceIdleInternal);
-        verifyAlwaysOnStateChanged(new String[] {PKGS[1]},
-                new VpnProfileState(VpnProfileState.STATE_DISCONNECTED,
-                        null /* sessionKey */, true /* alwaysOn */, false /* lockdown */));
-
-        // Enable VPN always-on for PKGS[2].
-        assertTrue(vpn.setAlwaysOnPackage(PKGS[2], false /* lockdown */,
-                null /* lockdownAllowlist */));
-        verifyPowerSaveTempWhitelistApp(PKGS[2]);
-        reset(mDeviceIdleInternal);
-        // PKGS[1] is replaced with PKGS[2].
-        // Pass 2 VpnProfileState objects to verifyVpnManagerEvent(), the first one is sent to
-        // PKGS[1] to notify PKGS[1] that the VPN always-on is disabled, the second one is sent to
-        // PKGS[2] to notify PKGS[2] that the VPN always-on is enabled.
-        verifyAlwaysOnStateChanged(new String[] {PKGS[1], PKGS[2]},
-                new VpnProfileState(VpnProfileState.STATE_DISCONNECTED,
-                        null /* sessionKey */, false /* alwaysOn */, false /* lockdown */),
-                new VpnProfileState(VpnProfileState.STATE_DISCONNECTED,
-                        null /* sessionKey */, true /* alwaysOn */, false /* lockdown */));
-    }
-
-    @Test
-    public void testReconnectVpnManagerVpnWithAlwaysOnEnabled() throws Exception {
-        final Vpn vpn = createVpn(AppOpsManager.OPSTR_ACTIVATE_PLATFORM_VPN);
-        when(mVpnProfileStore.get(vpn.getProfileNameForPackage(TEST_VPN_PKG)))
-                .thenReturn(mVpnProfile.encode());
-        vpn.startVpnProfile(TEST_VPN_PKG);
-        verifyPlatformVpnIsActivated(TEST_VPN_PKG);
-
-        // Enable VPN always-on for TEST_VPN_PKG.
-        assertTrue(vpn.setAlwaysOnPackage(TEST_VPN_PKG, false /* lockdown */,
-                null /* lockdownAllowlist */));
-
-        // Reset to verify next startVpnProfile.
-        reset(mAppOps);
-
-        vpn.stopVpnProfile(TEST_VPN_PKG);
-
-        // Reconnect the vpn with different package will cause exception.
-        assertThrows(SecurityException.class, () -> vpn.startVpnProfile(PKGS[0]));
-
-        // Reconnect the vpn again with the vpn always on package w/o exception.
-        vpn.startVpnProfile(TEST_VPN_PKG);
-        verifyPlatformVpnIsActivated(TEST_VPN_PKG);
-    }
-
-    @Test
-    public void testLockdown_enableDisableWhileConnected() throws Exception {
-        final PlatformVpnSnapshot vpnSnapShot = verifySetupPlatformVpn(
-                createIkeConfig(createIkeConnectInfo(), true /* isMobikeEnabled */));
-
-        final InOrder order = inOrder(mTestDeps);
-        order.verify(mTestDeps, timeout(TIMEOUT_CROSSTHREAD_MS))
-                .newNetworkAgent(any(), any(), any(), any(), any(), any(),
-                        argThat(config -> config.allowBypass), any(), any());
-
-        // Make VPN lockdown.
-        assertTrue(vpnSnapShot.vpn.setAlwaysOnPackage(TEST_VPN_PKG, true /* lockdown */,
-                null /* lockdownAllowlist */));
-
-        order.verify(mTestDeps, timeout(TIMEOUT_CROSSTHREAD_MS))
-                .newNetworkAgent(any(), any(), any(), any(), any(), any(),
-                argThat(config -> !config.allowBypass), any(), any());
-
-        // Disable lockdown.
-        assertTrue(vpnSnapShot.vpn.setAlwaysOnPackage(TEST_VPN_PKG, false /* lockdown */,
-                null /* lockdownAllowlist */));
-
-        order.verify(mTestDeps, timeout(TIMEOUT_CROSSTHREAD_MS))
-                .newNetworkAgent(any(), any(), any(), any(), any(), any(),
-                        argThat(config -> config.allowBypass), any(), any());
-    }
-
-    @Test
-    public void testSetPackageAuthorizationVpnService() throws Exception {
-        final Vpn vpn = createVpn();
-
-        assertTrue(vpn.setPackageAuthorization(TEST_VPN_PKG, VpnManager.TYPE_VPN_SERVICE));
-        verify(mAppOps)
-                .setMode(
-                        eq(AppOpsManager.OPSTR_ACTIVATE_VPN),
-                        eq(UserHandle.getUid(PRIMARY_USER.id, Process.myUid())),
-                        eq(TEST_VPN_PKG),
-                        eq(AppOpsManager.MODE_ALLOWED));
-    }
-
-    @Test
-    public void testSetPackageAuthorizationPlatformVpn() throws Exception {
-        final Vpn vpn = createVpn();
-
-        assertTrue(vpn.setPackageAuthorization(TEST_VPN_PKG, TYPE_VPN_PLATFORM));
-        verify(mAppOps)
-                .setMode(
-                        eq(AppOpsManager.OPSTR_ACTIVATE_PLATFORM_VPN),
-                        eq(UserHandle.getUid(PRIMARY_USER.id, Process.myUid())),
-                        eq(TEST_VPN_PKG),
-                        eq(AppOpsManager.MODE_ALLOWED));
-    }
-
-    @Test
-    public void testSetPackageAuthorizationRevokeAuthorization() throws Exception {
-        final Vpn vpn = createVpn();
-
-        assertTrue(vpn.setPackageAuthorization(TEST_VPN_PKG, VpnManager.TYPE_VPN_NONE));
-        verify(mAppOps)
-                .setMode(
-                        eq(AppOpsManager.OPSTR_ACTIVATE_VPN),
-                        eq(UserHandle.getUid(PRIMARY_USER.id, Process.myUid())),
-                        eq(TEST_VPN_PKG),
-                        eq(AppOpsManager.MODE_IGNORED));
-        verify(mAppOps)
-                .setMode(
-                        eq(AppOpsManager.OPSTR_ACTIVATE_PLATFORM_VPN),
-                        eq(UserHandle.getUid(PRIMARY_USER.id, Process.myUid())),
-                        eq(TEST_VPN_PKG),
-                        eq(AppOpsManager.MODE_IGNORED));
-    }
-
-    private NetworkCallback triggerOnAvailableAndGetCallback() throws Exception {
-        return triggerOnAvailableAndGetCallback(new NetworkCapabilities.Builder().build());
-    }
-
-    private NetworkCallback triggerOnAvailableAndGetCallback(
-            @NonNull final NetworkCapabilities caps) throws Exception {
-        final ArgumentCaptor<NetworkCallback> networkCallbackCaptor =
-                ArgumentCaptor.forClass(NetworkCallback.class);
-        verify(mConnectivityManager, timeout(TEST_TIMEOUT_MS))
-                .registerSystemDefaultNetworkCallback(networkCallbackCaptor.capture(), any());
-
-        // onAvailable() will trigger onDefaultNetworkChanged(), so NetdUtils#setInterfaceUp will be
-        // invoked. Set the return value of INetd#interfaceGetCfg to prevent NullPointerException.
-        final InterfaceConfigurationParcel config = new InterfaceConfigurationParcel();
-        config.flags = new String[] {IF_STATE_DOWN};
-        when(mNetd.interfaceGetCfg(anyString())).thenReturn(config);
-        final NetworkCallback cb = networkCallbackCaptor.getValue();
-        cb.onAvailable(TEST_NETWORK);
-        // Trigger onCapabilitiesChanged() and onLinkPropertiesChanged() so the test can verify that
-        // if NetworkCapabilities and LinkProperties of underlying network will be sent/cleared or
-        // not.
-        // See verifyVpnManagerEvent().
-        cb.onCapabilitiesChanged(TEST_NETWORK, caps);
-        cb.onLinkPropertiesChanged(TEST_NETWORK, new LinkProperties());
-        return cb;
-    }
-
-    private void verifyInterfaceSetCfgWithFlags(String flag) throws Exception {
-        // Add a timeout for waiting for interfaceSetCfg to be called.
-        verify(mNetd, timeout(TEST_TIMEOUT_MS)).interfaceSetCfg(argThat(
-                config -> Arrays.asList(config.flags).contains(flag)));
-    }
-
-    private void doTestPlatformVpnWithException(IkeException exception,
-            String category, int errorType, int errorCode) throws Exception {
-        final ArgumentCaptor<IkeSessionCallback> captor =
-                ArgumentCaptor.forClass(IkeSessionCallback.class);
-
-        final Vpn vpn = createVpn(AppOpsManager.OPSTR_ACTIVATE_PLATFORM_VPN);
-        when(mVpnProfileStore.get(vpn.getProfileNameForPackage(TEST_VPN_PKG)))
-                .thenReturn(mVpnProfile.encode());
-
-        doReturn(new NetworkCapabilities()).when(mConnectivityManager)
-                .getRedactedNetworkCapabilitiesForPackage(any(), anyInt(), anyString());
-        doReturn(new LinkProperties()).when(mConnectivityManager)
-                .getRedactedLinkPropertiesForPackage(any(), anyInt(), anyString());
-
-        final String sessionKey = vpn.startVpnProfile(TEST_VPN_PKG);
-        final Set<Range<Integer>> uidRanges = rangeSet(PRIMARY_USER_RANGE);
-        // This is triggered by Ikev2VpnRunner constructor.
-        verify(mConnectivityManager, times(1)).setVpnDefaultForUids(eq(sessionKey), eq(uidRanges));
-        final NetworkCallback cb = triggerOnAvailableAndGetCallback();
-
-        verifyInterfaceSetCfgWithFlags(IF_STATE_UP);
-
-        // Wait for createIkeSession() to be called before proceeding in order to ensure consistent
-        // state
-        verify(mIkev2SessionCreator, timeout(TEST_TIMEOUT_MS))
-                .createIkeSession(any(), any(), any(), any(), captor.capture(), any());
-        // This is triggered by Vpn#startOrMigrateIkeSession().
-        verify(mConnectivityManager, times(2)).setVpnDefaultForUids(eq(sessionKey), eq(uidRanges));
-        reset(mIkev2SessionCreator);
-        // For network lost case, the process should be triggered by calling onLost(), which is the
-        // same process with the real case.
-        if (errorCode == VpnManager.ERROR_CODE_NETWORK_LOST) {
-            cb.onLost(TEST_NETWORK);
-            verify(mExecutor, atLeastOnce()).schedule(any(Runnable.class), anyLong(), any());
-        } else {
-            final IkeSessionCallback ikeCb = captor.getValue();
-            mExecutor.execute(() -> ikeCb.onClosedWithException(exception));
-        }
-
-        verifyPowerSaveTempWhitelistApp(TEST_VPN_PKG);
-        reset(mDeviceIdleInternal);
-        verifyVpnManagerEvent(sessionKey, category, errorType, errorCode,
-                // VPN NetworkAgnet does not switch to CONNECTED in the test, and the state is not
-                // important here. Verify that the state as it is, i.e. CONNECTING state.
-                new String[] {TEST_VPN_PKG}, new VpnProfileState(VpnProfileState.STATE_CONNECTING,
-                        sessionKey, false /* alwaysOn */, false /* lockdown */));
-        if (errorType == VpnManager.ERROR_CLASS_NOT_RECOVERABLE) {
-            verify(mConnectivityManager).setVpnDefaultForUids(eq(sessionKey),
-                    eq(Collections.EMPTY_LIST));
-            verify(mConnectivityManager, timeout(TEST_TIMEOUT_MS))
-                    .unregisterNetworkCallback(eq(cb));
-        } else if (errorType == VpnManager.ERROR_CLASS_RECOVERABLE
-                // Vpn won't retry when there is no usable underlying network.
-                && errorCode != VpnManager.ERROR_CODE_NETWORK_LOST) {
-            int retryIndex = 0;
-            // First failure occurred above.
-            final IkeSessionCallback retryCb = verifyRetryAndGetNewIkeCb(retryIndex++);
-            // Trigger 2 more failures to let the retry delay increase to 5s.
-            mExecutor.execute(() -> retryCb.onClosedWithException(exception));
-            final IkeSessionCallback retryCb2 = verifyRetryAndGetNewIkeCb(retryIndex++);
-            mExecutor.execute(() -> retryCb2.onClosedWithException(exception));
-            final IkeSessionCallback retryCb3 = verifyRetryAndGetNewIkeCb(retryIndex++);
-
-            // setVpnDefaultForUids may be called again but the uidRanges should not change.
-            verify(mConnectivityManager, atLeast(2)).setVpnDefaultForUids(eq(sessionKey),
-                    mUidRangesCaptor.capture());
-            final List<Collection<Range<Integer>>> capturedUidRanges =
-                    mUidRangesCaptor.getAllValues();
-            for (int i = 2; i < capturedUidRanges.size(); i++) {
-                // Assert equals no order.
-                assertTrue(
-                        "uid ranges should not be modified. Expected: " + uidRanges
-                                + ", actual: " + capturedUidRanges.get(i),
-                        capturedUidRanges.get(i).containsAll(uidRanges)
-                                && capturedUidRanges.get(i).size() == uidRanges.size());
-            }
-
-            // A fourth failure will cause the retry delay to be greater than 5s.
-            mExecutor.execute(() -> retryCb3.onClosedWithException(exception));
-            verifyRetryAndGetNewIkeCb(retryIndex++);
-
-            // The VPN network preference will be cleared when the retry delay is greater than 5s.
-            verify(mConnectivityManager).setVpnDefaultForUids(eq(sessionKey),
-                    eq(Collections.EMPTY_LIST));
-        }
-    }
-
-    private IkeSessionCallback verifyRetryAndGetNewIkeCb(int retryIndex) {
-        final ArgumentCaptor<IkeSessionCallback> ikeCbCaptor =
-                ArgumentCaptor.forClass(IkeSessionCallback.class);
-
-        // Verify retry is scheduled
-        final long expectedDelayMs = mTestDeps.getNextRetryDelayMs(retryIndex);
-        verify(mExecutor, timeout(TEST_TIMEOUT_MS)).schedule(any(Runnable.class),
-                eq(expectedDelayMs), eq(TimeUnit.MILLISECONDS));
-
-        verify(mIkev2SessionCreator, timeout(TEST_TIMEOUT_MS + expectedDelayMs))
-                .createIkeSession(any(), any(), any(), any(), ikeCbCaptor.capture(), any());
-
-        // Forget the mIkev2SessionCreator#createIkeSession call and mExecutor#schedule call
-        // for the next retry verification
-        resetIkev2SessionCreator(mIkeSessionWrapper);
-
-        return ikeCbCaptor.getValue();
-    }
-
-    @Test
-    public void testStartPlatformVpnAuthenticationFailed() throws Exception {
-        final IkeProtocolException exception = mock(IkeProtocolException.class);
-        final int errorCode = IkeProtocolException.ERROR_TYPE_AUTHENTICATION_FAILED;
-        when(exception.getErrorType()).thenReturn(errorCode);
-        doTestPlatformVpnWithException(exception,
-                VpnManager.CATEGORY_EVENT_IKE_ERROR, VpnManager.ERROR_CLASS_NOT_RECOVERABLE,
-                errorCode);
-    }
-
-    @Test
-    public void testStartPlatformVpnFailedWithRecoverableError() throws Exception {
-        final IkeProtocolException exception = mock(IkeProtocolException.class);
-        final int errorCode = IkeProtocolException.ERROR_TYPE_TEMPORARY_FAILURE;
-        when(exception.getErrorType()).thenReturn(errorCode);
-        doTestPlatformVpnWithException(exception,
-                VpnManager.CATEGORY_EVENT_IKE_ERROR, VpnManager.ERROR_CLASS_RECOVERABLE, errorCode);
-    }
-
-    @Test
-    public void testStartPlatformVpnFailedWithUnknownHostException() throws Exception {
-        final IkeNonProtocolException exception = mock(IkeNonProtocolException.class);
-        final UnknownHostException unknownHostException = new UnknownHostException();
-        final int errorCode = VpnManager.ERROR_CODE_NETWORK_UNKNOWN_HOST;
-        when(exception.getCause()).thenReturn(unknownHostException);
-        doTestPlatformVpnWithException(exception,
-                VpnManager.CATEGORY_EVENT_NETWORK_ERROR, VpnManager.ERROR_CLASS_RECOVERABLE,
-                errorCode);
-    }
-
-    @Test
-    public void testStartPlatformVpnFailedWithIkeTimeoutException() throws Exception {
-        final IkeNonProtocolException exception = mock(IkeNonProtocolException.class);
-        final IkeTimeoutException ikeTimeoutException =
-                new IkeTimeoutException("IkeTimeoutException");
-        final int errorCode = VpnManager.ERROR_CODE_NETWORK_PROTOCOL_TIMEOUT;
-        when(exception.getCause()).thenReturn(ikeTimeoutException);
-        doTestPlatformVpnWithException(exception,
-                VpnManager.CATEGORY_EVENT_NETWORK_ERROR, VpnManager.ERROR_CLASS_RECOVERABLE,
-                errorCode);
-    }
-
-    @Test
-    public void testStartPlatformVpnFailedWithIkeNetworkLostException() throws Exception {
-        final IkeNetworkLostException exception = new IkeNetworkLostException(
-                new Network(100));
-        doTestPlatformVpnWithException(exception,
-                VpnManager.CATEGORY_EVENT_NETWORK_ERROR, VpnManager.ERROR_CLASS_RECOVERABLE,
-                VpnManager.ERROR_CODE_NETWORK_LOST);
-    }
-
-    @Test
-    public void testStartPlatformVpnFailedWithIOException() throws Exception {
-        final IkeNonProtocolException exception = mock(IkeNonProtocolException.class);
-        final IOException ioException = new IOException();
-        final int errorCode = VpnManager.ERROR_CODE_NETWORK_IO;
-        when(exception.getCause()).thenReturn(ioException);
-        doTestPlatformVpnWithException(exception,
-                VpnManager.CATEGORY_EVENT_NETWORK_ERROR, VpnManager.ERROR_CLASS_RECOVERABLE,
-                errorCode);
-    }
-
-    @Test
-    public void testStartPlatformVpnIllegalArgumentExceptionInSetup() throws Exception {
-        when(mIkev2SessionCreator.createIkeSession(any(), any(), any(), any(), any(), any()))
-                .thenThrow(new IllegalArgumentException());
-        final Vpn vpn = startLegacyVpn(createVpn(PRIMARY_USER.id), mVpnProfile);
-        final NetworkCallback cb = triggerOnAvailableAndGetCallback();
-
-        verifyInterfaceSetCfgWithFlags(IF_STATE_UP);
-
-        // Wait for createIkeSession() to be called before proceeding in order to ensure consistent
-        // state
-        verify(mConnectivityManager, timeout(TEST_TIMEOUT_MS)).unregisterNetworkCallback(eq(cb));
-        assertEquals(LegacyVpnInfo.STATE_FAILED, vpn.getLegacyVpnInfo().state);
-    }
-
-    @Test
-    public void testVpnManagerEventWillNotBeSentToSettingsVpn() throws Exception {
-        startLegacyVpn(createVpn(PRIMARY_USER.id), mVpnProfile);
-        triggerOnAvailableAndGetCallback();
-
-        verifyInterfaceSetCfgWithFlags(IF_STATE_UP);
-
-        final IkeNonProtocolException exception = mock(IkeNonProtocolException.class);
-        final IkeTimeoutException ikeTimeoutException =
-                new IkeTimeoutException("IkeTimeoutException");
-        when(exception.getCause()).thenReturn(ikeTimeoutException);
-
-        final ArgumentCaptor<IkeSessionCallback> captor =
-                ArgumentCaptor.forClass(IkeSessionCallback.class);
-        verify(mIkev2SessionCreator, timeout(TEST_TIMEOUT_MS))
-                .createIkeSession(any(), any(), any(), any(), captor.capture(), any());
-        final IkeSessionCallback ikeCb = captor.getValue();
-        ikeCb.onClosedWithException(exception);
-
-        final Context userContext =
-                mContext.createContextAsUser(UserHandle.of(PRIMARY_USER.id), 0 /* flags */);
-        verify(userContext, never()).startService(any());
-    }
-
-    private void setAndVerifyAlwaysOnPackage(Vpn vpn, int uid, boolean lockdownEnabled) {
-        assertTrue(vpn.setAlwaysOnPackage(TEST_VPN_PKG, lockdownEnabled, null));
-
-        verify(mVpnProfileStore).get(eq(vpn.getProfileNameForPackage(TEST_VPN_PKG)));
-        verify(mAppOps).setMode(
-                eq(AppOpsManager.OPSTR_ACTIVATE_PLATFORM_VPN), eq(uid), eq(TEST_VPN_PKG),
-                eq(AppOpsManager.MODE_ALLOWED));
-
-        verify(mSystemServices).settingsSecurePutStringForUser(
-                eq(Settings.Secure.ALWAYS_ON_VPN_APP), eq(TEST_VPN_PKG), eq(PRIMARY_USER.id));
-        verify(mSystemServices).settingsSecurePutIntForUser(
-                eq(Settings.Secure.ALWAYS_ON_VPN_LOCKDOWN), eq(lockdownEnabled ? 1 : 0),
-                eq(PRIMARY_USER.id));
-        verify(mSystemServices).settingsSecurePutStringForUser(
-                eq(Settings.Secure.ALWAYS_ON_VPN_LOCKDOWN_WHITELIST), eq(""), eq(PRIMARY_USER.id));
-    }
-
-    @Test
-    public void testSetAndStartAlwaysOnVpn() throws Exception {
-        final Vpn vpn = createVpn(PRIMARY_USER.id);
-        setMockedUsers(PRIMARY_USER);
-
-        // UID checks must return a different UID; otherwise it'll be treated as already prepared.
-        final int uid = Process.myUid() + 1;
-        when(mPackageManager.getPackageUidAsUser(eq(TEST_VPN_PKG), anyInt()))
-                .thenReturn(uid);
-        when(mVpnProfileStore.get(vpn.getProfileNameForPackage(TEST_VPN_PKG)))
-                .thenReturn(mVpnProfile.encode());
-
-        setAndVerifyAlwaysOnPackage(vpn, uid, false);
-        assertTrue(vpn.startAlwaysOnVpn());
-
-        // TODO: Test the Ikev2VpnRunner started up properly. Relies on utility methods added in
-        // a subsequent CL.
-    }
-
-    private Vpn startLegacyVpn(final Vpn vpn, final VpnProfile vpnProfile) throws Exception {
-        setMockedUsers(PRIMARY_USER);
-        vpn.startLegacyVpn(vpnProfile);
-        return vpn;
-    }
-
-    private IkeSessionConnectionInfo createIkeConnectInfo() {
-        return new IkeSessionConnectionInfo(TEST_VPN_CLIENT_IP, TEST_VPN_SERVER_IP, TEST_NETWORK);
-    }
-
-    private IkeSessionConnectionInfo createIkeConnectInfo_2() {
-        return new IkeSessionConnectionInfo(
-                TEST_VPN_CLIENT_IP_2, TEST_VPN_SERVER_IP_2, TEST_NETWORK_2);
-    }
-
-    private IkeSessionConfiguration createIkeConfig(
-            IkeSessionConnectionInfo ikeConnectInfo, boolean isMobikeEnabled) {
-        final IkeSessionConfiguration.Builder builder =
-                new IkeSessionConfiguration.Builder(ikeConnectInfo);
-
-        if (isMobikeEnabled) {
-            builder.addIkeExtension(EXTENSION_TYPE_MOBIKE);
-        }
-
-        return builder.build();
-    }
-
-    private ChildSessionConfiguration createChildConfig() {
-        return new ChildSessionConfiguration.Builder(
-                        Arrays.asList(IN_TS, IN_TS6), Arrays.asList(OUT_TS, OUT_TS6))
-                .addInternalAddress(new LinkAddress(TEST_VPN_INTERNAL_IP, IP4_PREFIX_LEN))
-                .addInternalAddress(new LinkAddress(TEST_VPN_INTERNAL_IP6, IP6_PREFIX_LEN))
-                .addInternalDnsServer(TEST_VPN_INTERNAL_DNS)
-                .addInternalDnsServer(TEST_VPN_INTERNAL_DNS6)
-                .build();
-    }
-
-    private IpSecTransform createIpSecTransform() {
-        return new IpSecTransform(mContext, new IpSecConfig());
-    }
-
-    private void verifyApplyTunnelModeTransforms(int expectedTimes) throws Exception {
-        verify(mIpSecService, times(expectedTimes)).applyTunnelModeTransform(
-                eq(TEST_TUNNEL_RESOURCE_ID), eq(IpSecManager.DIRECTION_IN),
-                anyInt(), anyString());
-        verify(mIpSecService, times(expectedTimes)).applyTunnelModeTransform(
-                eq(TEST_TUNNEL_RESOURCE_ID), eq(IpSecManager.DIRECTION_OUT),
-                anyInt(), anyString());
-    }
-
-    private Pair<IkeSessionCallback, ChildSessionCallback> verifyCreateIkeAndCaptureCbs()
-            throws Exception {
-        final ArgumentCaptor<IkeSessionCallback> ikeCbCaptor =
-                ArgumentCaptor.forClass(IkeSessionCallback.class);
-        final ArgumentCaptor<ChildSessionCallback> childCbCaptor =
-                ArgumentCaptor.forClass(ChildSessionCallback.class);
-
-        verify(mIkev2SessionCreator, timeout(TEST_TIMEOUT_MS)).createIkeSession(
-                any(), any(), any(), any(), ikeCbCaptor.capture(), childCbCaptor.capture());
-
-        return new Pair<>(ikeCbCaptor.getValue(), childCbCaptor.getValue());
-    }
-
-    private static class PlatformVpnSnapshot {
-        public final Vpn vpn;
-        public final NetworkCallback nwCb;
-        public final IkeSessionCallback ikeCb;
-        public final ChildSessionCallback childCb;
-
-        PlatformVpnSnapshot(Vpn vpn, NetworkCallback nwCb,
-                IkeSessionCallback ikeCb, ChildSessionCallback childCb) {
-            this.vpn = vpn;
-            this.nwCb = nwCb;
-            this.ikeCb = ikeCb;
-            this.childCb = childCb;
-        }
-    }
-
-    private PlatformVpnSnapshot verifySetupPlatformVpn(IkeSessionConfiguration ikeConfig)
-            throws Exception {
-        return verifySetupPlatformVpn(ikeConfig, true);
-    }
-
-    private PlatformVpnSnapshot verifySetupPlatformVpn(
-            IkeSessionConfiguration ikeConfig, boolean mtuSupportsIpv6) throws Exception {
-        return verifySetupPlatformVpn(mVpnProfile, ikeConfig, mtuSupportsIpv6);
-    }
-
-    private PlatformVpnSnapshot verifySetupPlatformVpn(VpnProfile vpnProfile,
-            IkeSessionConfiguration ikeConfig, boolean mtuSupportsIpv6) throws Exception {
-        return verifySetupPlatformVpn(vpnProfile, ikeConfig,
-                new NetworkCapabilities.Builder().build() /* underlying network caps */,
-                mtuSupportsIpv6, false /* areLongLivedTcpConnectionsExpensive */);
-    }
-
-    private PlatformVpnSnapshot verifySetupPlatformVpn(VpnProfile vpnProfile,
-            IkeSessionConfiguration ikeConfig,
-            @NonNull final NetworkCapabilities underlyingNetworkCaps,
-            boolean mtuSupportsIpv6,
-            boolean areLongLivedTcpConnectionsExpensive) throws Exception {
-        if (!mtuSupportsIpv6) {
-            doReturn(IPV6_MIN_MTU - 1).when(mTestDeps).calculateVpnMtu(any(), anyInt(), anyInt(),
-                    anyBoolean());
-        }
-
-        doReturn(mMockNetworkAgent).when(mTestDeps)
-                .newNetworkAgent(
-                        any(), any(), anyString(), any(), any(), any(), any(), any(), any());
-        doReturn(TEST_NETWORK).when(mMockNetworkAgent).getNetwork();
-
-        final Vpn vpn = createVpn(AppOpsManager.OPSTR_ACTIVATE_PLATFORM_VPN);
-        when(mVpnProfileStore.get(vpn.getProfileNameForPackage(TEST_VPN_PKG)))
-                .thenReturn(vpnProfile.encode());
-
-        final String sessionKey = vpn.startVpnProfile(TEST_VPN_PKG);
-        final Set<Range<Integer>> uidRanges = Collections.singleton(PRIMARY_USER_RANGE);
-        verify(mConnectivityManager).setVpnDefaultForUids(eq(sessionKey), eq(uidRanges));
-        final NetworkCallback nwCb = triggerOnAvailableAndGetCallback(underlyingNetworkCaps);
-        // There are 4 interactions with the executor.
-        // - Network available
-        // - LP change
-        // - NC change
-        // - schedule() calls in scheduleStartIkeSession()
-        // The first 3 calls are triggered from Executor.execute(). The execute() will also call to
-        // schedule() with 0 delay. Verify the exact interaction here so that it won't cause flakes
-        // in the follow-up flow.
-        verify(mExecutor, timeout(TEST_TIMEOUT_MS).times(4))
-                .schedule(any(Runnable.class), anyLong(), any());
-        reset(mExecutor);
-
-        // Mock the setup procedure by firing callbacks
-        final Pair<IkeSessionCallback, ChildSessionCallback> cbPair =
-                verifyCreateIkeAndCaptureCbs();
-        final IkeSessionCallback ikeCb = cbPair.first;
-        final ChildSessionCallback childCb = cbPair.second;
-
-        ikeCb.onOpened(ikeConfig);
-        childCb.onIpSecTransformCreated(createIpSecTransform(), IpSecManager.DIRECTION_IN);
-        childCb.onIpSecTransformCreated(createIpSecTransform(), IpSecManager.DIRECTION_OUT);
-        childCb.onOpened(createChildConfig());
-
-        // Verification VPN setup
-        verifyApplyTunnelModeTransforms(1);
-
-        ArgumentCaptor<LinkProperties> lpCaptor = ArgumentCaptor.forClass(LinkProperties.class);
-        ArgumentCaptor<NetworkCapabilities> ncCaptor =
-                ArgumentCaptor.forClass(NetworkCapabilities.class);
-        ArgumentCaptor<NetworkAgentConfig> nacCaptor =
-                ArgumentCaptor.forClass(NetworkAgentConfig.class);
-        verify(mTestDeps).newNetworkAgent(
-                any(), any(), anyString(), ncCaptor.capture(), lpCaptor.capture(),
-                any(), nacCaptor.capture(), any(), any());
-        verify(mIkeSessionWrapper).setUnderpinnedNetwork(TEST_NETWORK);
-        // Check LinkProperties
-        final LinkProperties lp = lpCaptor.getValue();
-        final List<RouteInfo> expectedRoutes =
-                new ArrayList<>(
-                        Arrays.asList(
-                                new RouteInfo(
-                                        new IpPrefix(Inet4Address.ANY, 0),
-                                        null /* gateway */,
-                                        TEST_IFACE_NAME,
-                                        RouteInfo.RTN_UNICAST)));
-        final List<LinkAddress> expectedAddresses =
-                new ArrayList<>(
-                        Arrays.asList(new LinkAddress(TEST_VPN_INTERNAL_IP, IP4_PREFIX_LEN)));
-        final List<InetAddress> expectedDns = new ArrayList<>(Arrays.asList(TEST_VPN_INTERNAL_DNS));
-
-        if (mtuSupportsIpv6) {
-            expectedRoutes.add(
-                    new RouteInfo(
-                            new IpPrefix(Inet6Address.ANY, 0),
-                            null /* gateway */,
-                            TEST_IFACE_NAME,
-                            RouteInfo.RTN_UNICAST));
-            expectedAddresses.add(new LinkAddress(TEST_VPN_INTERNAL_IP6, IP6_PREFIX_LEN));
-            expectedDns.add(TEST_VPN_INTERNAL_DNS6);
-        } else {
-            expectedRoutes.add(
-                    new RouteInfo(
-                            new IpPrefix(Inet6Address.ANY, 0),
-                            null /* gateway */,
-                            TEST_IFACE_NAME,
-                            RTN_UNREACHABLE));
-        }
-
-        assertEquals(expectedRoutes, lp.getRoutes());
-        assertEquals(expectedAddresses, lp.getLinkAddresses());
-        assertEquals(expectedDns, lp.getDnsServers());
-
-        // Check NetworkCapabilities
-        assertEquals(Arrays.asList(TEST_NETWORK), ncCaptor.getValue().getUnderlyingNetworks());
-
-        // Check if allowBypass is set or not.
-        assertTrue(nacCaptor.getValue().isBypassableVpn());
-        // Check if extra info for VPN is set.
-        assertTrue(nacCaptor.getValue().getLegacyExtraInfo().contains(TEST_VPN_PKG));
-        final VpnTransportInfo info = (VpnTransportInfo) ncCaptor.getValue().getTransportInfo();
-        assertTrue(info.isBypassable());
-        assertEquals(areLongLivedTcpConnectionsExpensive,
-                info.areLongLivedTcpConnectionsExpensive());
-        return new PlatformVpnSnapshot(vpn, nwCb, ikeCb, childCb);
-    }
-
-    @Test
-    public void testStartPlatformVpn() throws Exception {
-        final PlatformVpnSnapshot vpnSnapShot = verifySetupPlatformVpn(
-                createIkeConfig(createIkeConnectInfo(), true /* isMobikeEnabled */));
-        vpnSnapShot.vpn.mVpnRunner.exitVpnRunner();
-        verify(mConnectivityManager).setVpnDefaultForUids(anyString(), eq(Collections.EMPTY_LIST));
-    }
-
-    @Test
-    public void testMigrateIkeSession_FromIkeTunnConnParams_AutoTimerNoTimer() throws Exception {
-        doTestMigrateIkeSession_FromIkeTunnConnParams(
-                false /* isAutomaticIpVersionSelectionEnabled */,
-                true /* isAutomaticNattKeepaliveTimerEnabled */,
-                TEST_KEEPALIVE_TIMEOUT_UNSET /* keepaliveInProfile */,
-                ESP_IP_VERSION_AUTO /* ipVersionInProfile */,
-                ESP_ENCAP_TYPE_AUTO /* encapTypeInProfile */);
-    }
-
-    @Test
-    public void testMigrateIkeSession_FromIkeTunnConnParams_AutoTimerTimerSet() throws Exception {
-        doTestMigrateIkeSession_FromIkeTunnConnParams(
-                false /* isAutomaticIpVersionSelectionEnabled */,
-                true /* isAutomaticNattKeepaliveTimerEnabled */,
-                TEST_KEEPALIVE_TIMER /* keepaliveInProfile */,
-                ESP_IP_VERSION_AUTO /* ipVersionInProfile */,
-                ESP_ENCAP_TYPE_AUTO /* encapTypeInProfile */);
-    }
-
-    @Test
-    public void testMigrateIkeSession_FromIkeTunnConnParams_AutoIp() throws Exception {
-        doTestMigrateIkeSession_FromIkeTunnConnParams(
-                true /* isAutomaticIpVersionSelectionEnabled */,
-                false /* isAutomaticNattKeepaliveTimerEnabled */,
-                TEST_KEEPALIVE_TIMEOUT_UNSET /* keepaliveInProfile */,
-                ESP_IP_VERSION_AUTO /* ipVersionInProfile */,
-                ESP_ENCAP_TYPE_AUTO /* encapTypeInProfile */);
-    }
-
-    @Test
-    public void testMigrateIkeSession_FromIkeTunnConnParams_AssignedIpProtocol() throws Exception {
-        doTestMigrateIkeSession_FromIkeTunnConnParams(
-                false /* isAutomaticIpVersionSelectionEnabled */,
-                false /* isAutomaticNattKeepaliveTimerEnabled */,
-                TEST_KEEPALIVE_TIMEOUT_UNSET /* keepaliveInProfile */,
-                ESP_IP_VERSION_IPV4 /* ipVersionInProfile */,
-                ESP_ENCAP_TYPE_UDP /* encapTypeInProfile */);
-    }
-
-    @Test
-    public void testMigrateIkeSession_FromNotIkeTunnConnParams_AutoTimer() throws Exception {
-        doTestMigrateIkeSession_FromNotIkeTunnConnParams(
-                false /* isAutomaticIpVersionSelectionEnabled */,
-                true /* isAutomaticNattKeepaliveTimerEnabled */);
-    }
-
-    @Test
-    public void testMigrateIkeSession_FromNotIkeTunnConnParams_AutoIp() throws Exception {
-        doTestMigrateIkeSession_FromNotIkeTunnConnParams(
-                true /* isAutomaticIpVersionSelectionEnabled */,
-                false /* isAutomaticNattKeepaliveTimerEnabled */);
-    }
-
-    private void doTestMigrateIkeSession_FromNotIkeTunnConnParams(
-            boolean isAutomaticIpVersionSelectionEnabled,
-            boolean isAutomaticNattKeepaliveTimerEnabled) throws Exception {
-        final Ikev2VpnProfile ikeProfile =
-                new Ikev2VpnProfile.Builder(TEST_VPN_SERVER, TEST_VPN_IDENTITY)
-                        .setAuthPsk(TEST_VPN_PSK)
-                        .setBypassable(true /* isBypassable */)
-                        .setAutomaticNattKeepaliveTimerEnabled(isAutomaticNattKeepaliveTimerEnabled)
-                        .setAutomaticIpVersionSelectionEnabled(isAutomaticIpVersionSelectionEnabled)
-                        .build();
-
-        final int expectedKeepalive = isAutomaticNattKeepaliveTimerEnabled
-                ? AUTOMATIC_KEEPALIVE_DELAY_SECONDS
-                : DEFAULT_UDP_PORT_4500_NAT_TIMEOUT_SEC_INT;
-        doTestMigrateIkeSession(ikeProfile.toVpnProfile(),
-                expectedKeepalive,
-                ESP_IP_VERSION_AUTO /* expectedIpVersion */,
-                ESP_ENCAP_TYPE_AUTO /* expectedEncapType */,
-                new NetworkCapabilities.Builder().build());
-    }
-
-    private Ikev2VpnProfile makeIkeV2VpnProfile(
-            boolean isAutomaticIpVersionSelectionEnabled,
-            boolean isAutomaticNattKeepaliveTimerEnabled,
-            int keepaliveInProfile,
-            int ipVersionInProfile,
-            int encapTypeInProfile) {
-        // TODO: Update helper function in IkeSessionTestUtils to support building IkeSessionParams
-        // with IP version and encap type when mainline-prod branch support these two APIs.
-        final IkeSessionParams params = getTestIkeSessionParams(true /* testIpv6 */,
-                new IkeFqdnIdentification(TEST_IDENTITY), keepaliveInProfile);
-        final IkeSessionParams ikeSessionParams = new IkeSessionParams.Builder(params)
-                .setIpVersion(ipVersionInProfile)
-                .setEncapType(encapTypeInProfile)
-                .build();
-
-        final IkeTunnelConnectionParams tunnelParams =
-                new IkeTunnelConnectionParams(ikeSessionParams, CHILD_PARAMS);
-        return new Ikev2VpnProfile.Builder(tunnelParams)
-                .setBypassable(true)
-                .setAutomaticNattKeepaliveTimerEnabled(isAutomaticNattKeepaliveTimerEnabled)
-                .setAutomaticIpVersionSelectionEnabled(isAutomaticIpVersionSelectionEnabled)
-                .build();
-    }
-
-    private void doTestMigrateIkeSession_FromIkeTunnConnParams(
-            boolean isAutomaticIpVersionSelectionEnabled,
-            boolean isAutomaticNattKeepaliveTimerEnabled,
-            int keepaliveInProfile,
-            int ipVersionInProfile,
-            int encapTypeInProfile) throws Exception {
-        doTestMigrateIkeSession_FromIkeTunnConnParams(isAutomaticIpVersionSelectionEnabled,
-                isAutomaticNattKeepaliveTimerEnabled, keepaliveInProfile, ipVersionInProfile,
-                encapTypeInProfile, new NetworkCapabilities.Builder().build());
-    }
-
-    private void doTestMigrateIkeSession_FromIkeTunnConnParams(
-            boolean isAutomaticIpVersionSelectionEnabled,
-            boolean isAutomaticNattKeepaliveTimerEnabled,
-            int keepaliveInProfile,
-            int ipVersionInProfile,
-            int encapTypeInProfile,
-            @NonNull final NetworkCapabilities nc) throws Exception {
-        final Ikev2VpnProfile ikeProfile = makeIkeV2VpnProfile(
-                isAutomaticIpVersionSelectionEnabled,
-                isAutomaticNattKeepaliveTimerEnabled,
-                keepaliveInProfile,
-                ipVersionInProfile,
-                encapTypeInProfile);
-
-        final IkeSessionParams ikeSessionParams =
-                ikeProfile.getIkeTunnelConnectionParams().getIkeSessionParams();
-        final int expectedKeepalive = isAutomaticNattKeepaliveTimerEnabled
-                ? AUTOMATIC_KEEPALIVE_DELAY_SECONDS
-                : ikeSessionParams.getNattKeepAliveDelaySeconds();
-        final int expectedIpVersion = isAutomaticIpVersionSelectionEnabled
-                ? ESP_IP_VERSION_AUTO
-                : ikeSessionParams.getIpVersion();
-        final int expectedEncapType = isAutomaticIpVersionSelectionEnabled
-                ? ESP_ENCAP_TYPE_AUTO
-                : ikeSessionParams.getEncapType();
-        doTestMigrateIkeSession(ikeProfile.toVpnProfile(), expectedKeepalive,
-                expectedIpVersion, expectedEncapType, nc);
-    }
-
-    @Test
-    public void doTestMigrateIkeSession_Vcn() throws Exception {
-        final int expectedKeepalive = 2097; // Any unlikely number will do
-        final NetworkCapabilities vcnNc = new NetworkCapabilities.Builder()
-                .addTransportType(TRANSPORT_CELLULAR)
-                .setTransportInfo(new VcnTransportInfo(TEST_SUB_ID, expectedKeepalive))
-                .build();
-        final Ikev2VpnProfile ikev2VpnProfile = makeIkeV2VpnProfile(
-                true /* isAutomaticIpVersionSelectionEnabled */,
-                true /* isAutomaticNattKeepaliveTimerEnabled */,
-                234 /* keepaliveInProfile */, // Should be ignored, any value will do
-                ESP_IP_VERSION_IPV4, // Should be ignored
-                ESP_ENCAP_TYPE_UDP // Should be ignored
-        );
-        doTestMigrateIkeSession(
-                ikev2VpnProfile.toVpnProfile(),
-                expectedKeepalive,
-                ESP_IP_VERSION_AUTO /* expectedIpVersion */,
-                ESP_ENCAP_TYPE_AUTO /* expectedEncapType */,
-                vcnNc);
-    }
-
-    private void doTestMigrateIkeSession(
-            @NonNull final VpnProfile profile,
-            final int expectedKeepalive,
-            final int expectedIpVersion,
-            final int expectedEncapType,
-            @NonNull final NetworkCapabilities caps) throws Exception {
-        final PlatformVpnSnapshot vpnSnapShot =
-                verifySetupPlatformVpn(profile,
-                        createIkeConfig(createIkeConnectInfo(), true /* isMobikeEnabled */),
-                        caps /* underlying network capabilities */,
-                        false /* mtuSupportsIpv6 */,
-                        expectedKeepalive < DEFAULT_LONG_LIVED_TCP_CONNS_EXPENSIVE_TIMEOUT_SEC);
-        // Simulate a new network coming up
-        vpnSnapShot.nwCb.onAvailable(TEST_NETWORK_2);
-        verify(mIkeSessionWrapper, never()).setNetwork(any(), anyInt(), anyInt(), anyInt());
-
-        vpnSnapShot.nwCb.onCapabilitiesChanged(TEST_NETWORK_2, caps);
-        // Verify MOBIKE is triggered
-        verify(mIkeSessionWrapper, timeout(TEST_TIMEOUT_MS)).setNetwork(TEST_NETWORK_2,
-                expectedIpVersion, expectedEncapType, expectedKeepalive);
-
-        vpnSnapShot.vpn.mVpnRunner.exitVpnRunner();
-    }
-
-    @Test
-    public void testLinkPropertiesUpdateTriggerReevaluation() throws Exception {
-        final boolean hasV6 = true;
-
-        mockCarrierConfig(TEST_SUB_ID, TelephonyManager.SIM_STATE_LOADED, TEST_KEEPALIVE_TIMER,
-                PREFERRED_IKE_PROTOCOL_IPV6_ESP);
-        final IkeSessionParams params = getTestIkeSessionParams(hasV6,
-                new IkeFqdnIdentification(TEST_IDENTITY), TEST_KEEPALIVE_TIMER);
-        final IkeTunnelConnectionParams tunnelParams =
-                new IkeTunnelConnectionParams(params, CHILD_PARAMS);
-        final Ikev2VpnProfile ikeProfile = new Ikev2VpnProfile.Builder(tunnelParams)
-                .setBypassable(true)
-                .setAutomaticNattKeepaliveTimerEnabled(false)
-                .setAutomaticIpVersionSelectionEnabled(true)
-                .build();
-        final PlatformVpnSnapshot vpnSnapShot =
-                verifySetupPlatformVpn(ikeProfile.toVpnProfile(),
-                        createIkeConfig(createIkeConnectInfo(), true /* isMobikeEnabled */),
-                        new NetworkCapabilities.Builder().build() /* underlying network caps */,
-                        hasV6 /* mtuSupportsIpv6 */,
-                        false /* areLongLivedTcpConnectionsExpensive */);
-        reset(mExecutor);
-
-        // Simulate a new network coming up
-        final LinkProperties lp = new LinkProperties();
-        lp.addLinkAddress(new LinkAddress("192.0.2.2/32"));
-
-        // Have the executor use the real delay to make sure schedule() was called only
-        // once for all calls. Also, arrange for execute() not to call schedule() to avoid
-        // messing with the checks for schedule().
-        mExecutor.delayMs = TestExecutor.REAL_DELAY;
-        mExecutor.executeDirect = true;
-        vpnSnapShot.nwCb.onAvailable(TEST_NETWORK_2);
-        vpnSnapShot.nwCb.onCapabilitiesChanged(
-                TEST_NETWORK_2, new NetworkCapabilities.Builder().build());
-        vpnSnapShot.nwCb.onLinkPropertiesChanged(TEST_NETWORK_2, new LinkProperties(lp));
-        verify(mExecutor).schedule(any(Runnable.class), longThat(it -> it > 0), any());
-        reset(mExecutor);
-
-        final InOrder order = inOrder(mIkeSessionWrapper);
-
-        // Verify the network is started
-        order.verify(mIkeSessionWrapper, timeout(TIMEOUT_CROSSTHREAD_MS)).setNetwork(TEST_NETWORK_2,
-                ESP_IP_VERSION_AUTO, ESP_ENCAP_TYPE_AUTO, TEST_KEEPALIVE_TIMER);
-
-        // Send the same properties, check that no migration is scheduled
-        vpnSnapShot.nwCb.onLinkPropertiesChanged(TEST_NETWORK_2, new LinkProperties(lp));
-        verify(mExecutor, never()).schedule(any(Runnable.class), anyLong(), any());
-
-        // Add v6 address, verify MOBIKE is triggered
-        lp.addLinkAddress(new LinkAddress("2001:db8::1/64"));
-        vpnSnapShot.nwCb.onLinkPropertiesChanged(TEST_NETWORK_2, new LinkProperties(lp));
-        order.verify(mIkeSessionWrapper, timeout(TIMEOUT_CROSSTHREAD_MS)).setNetwork(TEST_NETWORK_2,
-                ESP_IP_VERSION_AUTO, ESP_ENCAP_TYPE_AUTO, TEST_KEEPALIVE_TIMER);
-
-        // Add another v4 address, verify MOBIKE is triggered
-        final LinkProperties stacked = new LinkProperties();
-        stacked.setInterfaceName("v4-" + lp.getInterfaceName());
-        stacked.addLinkAddress(new LinkAddress("192.168.0.1/32"));
-        lp.addStackedLink(stacked);
-        vpnSnapShot.nwCb.onLinkPropertiesChanged(TEST_NETWORK_2, new LinkProperties(lp));
-        order.verify(mIkeSessionWrapper, timeout(TIMEOUT_CROSSTHREAD_MS)).setNetwork(TEST_NETWORK_2,
-                ESP_IP_VERSION_AUTO, ESP_ENCAP_TYPE_AUTO, TEST_KEEPALIVE_TIMER);
-
-        vpnSnapShot.vpn.mVpnRunner.exitVpnRunner();
-    }
-
-    private void mockCarrierConfig(int subId, int simStatus, int keepaliveTimer, int ikeProtocol) {
-        final SubscriptionInfo subscriptionInfo = mock(SubscriptionInfo.class);
-        doReturn(subId).when(subscriptionInfo).getSubscriptionId();
-        doReturn(List.of(subscriptionInfo)).when(mSubscriptionManager)
-                .getActiveSubscriptionInfoList();
-
-        doReturn(simStatus).when(mTmPerSub).getSimApplicationState();
-        doReturn(TEST_MCCMNC).when(mTmPerSub).getSimOperator(subId);
-
-        final PersistableBundle persistableBundle = new PersistableBundle();
-        persistableBundle.putInt(KEY_MIN_UDP_PORT_4500_NAT_TIMEOUT_SEC_INT, keepaliveTimer);
-        persistableBundle.putInt(KEY_PREFERRED_IKE_PROTOCOL_INT, ikeProtocol);
-        // For CarrierConfigManager.isConfigForIdentifiedCarrier check
-        persistableBundle.putBoolean(KEY_CARRIER_CONFIG_APPLIED_BOOL, true);
-        doReturn(persistableBundle).when(mConfigManager).getConfigForSubId(subId);
-    }
-
-    private CarrierConfigManager.CarrierConfigChangeListener getCarrierConfigListener() {
-        final ArgumentCaptor<CarrierConfigManager.CarrierConfigChangeListener> listenerCaptor =
-                ArgumentCaptor.forClass(CarrierConfigManager.CarrierConfigChangeListener.class);
-
-        verify(mConfigManager).registerCarrierConfigChangeListener(any(), listenerCaptor.capture());
-
-        return listenerCaptor.getValue();
-    }
-
-    @Test
-    public void testNattKeepaliveTimerFromCarrierConfig_noSubId() throws Exception {
-        doTestReadCarrierConfig(new NetworkCapabilities(),
-                TelephonyManager.SIM_STATE_LOADED,
-                PREFERRED_IKE_PROTOCOL_IPV4_UDP,
-                AUTOMATIC_KEEPALIVE_DELAY_SECONDS /* expectedKeepaliveTimer */,
-                ESP_IP_VERSION_AUTO /* expectedIpVersion */,
-                ESP_ENCAP_TYPE_AUTO /* expectedEncapType */,
-                false /* expectedReadFromCarrierConfig*/,
-                true /* areLongLivedTcpConnectionsExpensive */);
-    }
-
-    @Test
-    public void testNattKeepaliveTimerFromCarrierConfig_simAbsent() throws Exception {
-        doTestReadCarrierConfig(new NetworkCapabilities.Builder().build(),
-                TelephonyManager.SIM_STATE_ABSENT,
-                PREFERRED_IKE_PROTOCOL_IPV4_UDP,
-                AUTOMATIC_KEEPALIVE_DELAY_SECONDS /* expectedKeepaliveTimer */,
-                ESP_IP_VERSION_AUTO /* expectedIpVersion */,
-                ESP_ENCAP_TYPE_AUTO /* expectedEncapType */,
-                false /* expectedReadFromCarrierConfig*/,
-                true /* areLongLivedTcpConnectionsExpensive */);
-    }
-
-    @Test
-    public void testNattKeepaliveTimerFromCarrierConfig() throws Exception {
-        doTestReadCarrierConfig(createTestCellNc(),
-                TelephonyManager.SIM_STATE_LOADED,
-                PREFERRED_IKE_PROTOCOL_AUTO,
-                TEST_KEEPALIVE_TIMER /* expectedKeepaliveTimer */,
-                ESP_IP_VERSION_AUTO /* expectedIpVersion */,
-                ESP_ENCAP_TYPE_AUTO /* expectedEncapType */,
-                true /* expectedReadFromCarrierConfig*/,
-                false /* areLongLivedTcpConnectionsExpensive */);
-    }
-
-    @Test
-    public void testNattKeepaliveTimerFromCarrierConfig_NotCell() throws Exception {
-        final NetworkCapabilities nc = new NetworkCapabilities.Builder()
-                .addTransportType(TRANSPORT_WIFI)
-                .setTransportInfo(new WifiInfo.Builder().build())
-                .build();
-        doTestReadCarrierConfig(nc,
-                TelephonyManager.SIM_STATE_LOADED,
-                PREFERRED_IKE_PROTOCOL_IPV4_UDP,
-                AUTOMATIC_KEEPALIVE_DELAY_SECONDS /* expectedKeepaliveTimer */,
-                ESP_IP_VERSION_AUTO /* expectedIpVersion */,
-                ESP_ENCAP_TYPE_AUTO /* expectedEncapType */,
-                false /* expectedReadFromCarrierConfig*/,
-                true /* areLongLivedTcpConnectionsExpensive */);
-    }
-
-    @Test
-    public void testPreferredIpProtocolFromCarrierConfig_v4UDP() throws Exception {
-        doTestReadCarrierConfig(createTestCellNc(),
-                TelephonyManager.SIM_STATE_LOADED,
-                PREFERRED_IKE_PROTOCOL_IPV4_UDP,
-                TEST_KEEPALIVE_TIMER /* expectedKeepaliveTimer */,
-                ESP_IP_VERSION_IPV4 /* expectedIpVersion */,
-                ESP_ENCAP_TYPE_UDP /* expectedEncapType */,
-                true /* expectedReadFromCarrierConfig*/,
-                false /* areLongLivedTcpConnectionsExpensive */);
-    }
-
-    @Test
-    public void testPreferredIpProtocolFromCarrierConfig_v6ESP() throws Exception {
-        doTestReadCarrierConfig(createTestCellNc(),
-                TelephonyManager.SIM_STATE_LOADED,
-                PREFERRED_IKE_PROTOCOL_IPV6_ESP,
-                TEST_KEEPALIVE_TIMER /* expectedKeepaliveTimer */,
-                ESP_IP_VERSION_IPV6 /* expectedIpVersion */,
-                ESP_ENCAP_TYPE_NONE /* expectedEncapType */,
-                true /* expectedReadFromCarrierConfig*/,
-                false /* areLongLivedTcpConnectionsExpensive */);
-    }
-
-    @Test
-    public void testPreferredIpProtocolFromCarrierConfig_v6UDP() throws Exception {
-        doTestReadCarrierConfig(createTestCellNc(),
-                TelephonyManager.SIM_STATE_LOADED,
-                PREFERRED_IKE_PROTOCOL_IPV6_UDP,
-                TEST_KEEPALIVE_TIMER /* expectedKeepaliveTimer */,
-                ESP_IP_VERSION_IPV6 /* expectedIpVersion */,
-                ESP_ENCAP_TYPE_UDP /* expectedEncapType */,
-                true /* expectedReadFromCarrierConfig*/,
-                false /* areLongLivedTcpConnectionsExpensive */);
-    }
-
-    private NetworkCapabilities createTestCellNc() {
-        return new NetworkCapabilities.Builder()
-                .addTransportType(TRANSPORT_CELLULAR)
-                .setNetworkSpecifier(new TelephonyNetworkSpecifier.Builder()
-                        .setSubscriptionId(TEST_SUB_ID)
-                        .build())
-                .build();
-    }
-
-    private void doTestReadCarrierConfig(NetworkCapabilities nc, int simState, int preferredIpProto,
-            int expectedKeepaliveTimer, int expectedIpVersion, int expectedEncapType,
-            boolean expectedReadFromCarrierConfig,
-            boolean areLongLivedTcpConnectionsExpensive)
-            throws Exception {
-        final Ikev2VpnProfile ikeProfile =
-                new Ikev2VpnProfile.Builder(TEST_VPN_SERVER, TEST_VPN_IDENTITY)
-                        .setAuthPsk(TEST_VPN_PSK)
-                        .setBypassable(true /* isBypassable */)
-                        .setAutomaticNattKeepaliveTimerEnabled(true)
-                        .setAutomaticIpVersionSelectionEnabled(true)
-                        .build();
-
-        final PlatformVpnSnapshot vpnSnapShot =
-                verifySetupPlatformVpn(ikeProfile.toVpnProfile(),
-                        createIkeConfig(createIkeConnectInfo(), true /* isMobikeEnabled */),
-                        new NetworkCapabilities.Builder().build() /* underlying network caps */,
-                        false /* mtuSupportsIpv6 */,
-                        true /* areLongLivedTcpConnectionsExpensive */);
-
-        final CarrierConfigManager.CarrierConfigChangeListener listener =
-                getCarrierConfigListener();
-
-        // Simulate a new network coming up
-        vpnSnapShot.nwCb.onAvailable(TEST_NETWORK_2);
-        // Migration will not be started until receiving network capabilities change.
-        verify(mIkeSessionWrapper, never()).setNetwork(any(), anyInt(), anyInt(), anyInt());
-
-        reset(mIkeSessionWrapper);
-        mockCarrierConfig(TEST_SUB_ID, simState, TEST_KEEPALIVE_TIMER, preferredIpProto);
-        vpnSnapShot.nwCb.onCapabilitiesChanged(TEST_NETWORK_2, nc);
-        verify(mIkeSessionWrapper, timeout(TEST_TIMEOUT_MS)).setNetwork(TEST_NETWORK_2,
-                expectedIpVersion, expectedEncapType, expectedKeepaliveTimer);
-        if (expectedReadFromCarrierConfig) {
-            final ArgumentCaptor<NetworkCapabilities> ncCaptor =
-                    ArgumentCaptor.forClass(NetworkCapabilities.class);
-            verify(mMockNetworkAgent, timeout(TEST_TIMEOUT_MS))
-                    .doSendNetworkCapabilities(ncCaptor.capture());
-
-            final VpnTransportInfo info =
-                    (VpnTransportInfo) ncCaptor.getValue().getTransportInfo();
-            assertEquals(areLongLivedTcpConnectionsExpensive,
-                    info.areLongLivedTcpConnectionsExpensive());
-        } else {
-            verify(mMockNetworkAgent, never()).doSendNetworkCapabilities(any());
-        }
-
-        reset(mExecutor);
-        reset(mIkeSessionWrapper);
-        reset(mMockNetworkAgent);
-
-        // Trigger carrier config change
-        listener.onCarrierConfigChanged(1 /* logicalSlotIndex */, TEST_SUB_ID,
-                -1 /* carrierId */, -1 /* specificCarrierId */);
-        verify(mIkeSessionWrapper).setNetwork(TEST_NETWORK_2,
-                expectedIpVersion, expectedEncapType, expectedKeepaliveTimer);
-        // Expect no NetworkCapabilities change.
-        // Call to doSendNetworkCapabilities() will not be triggered.
-        verify(mMockNetworkAgent, never()).doSendNetworkCapabilities(any());
-    }
-
-    @Test
-    public void testStartPlatformVpn_mtuDoesNotSupportIpv6() throws Exception {
-        final PlatformVpnSnapshot vpnSnapShot =
-                verifySetupPlatformVpn(
-                        createIkeConfig(createIkeConnectInfo(), true /* isMobikeEnabled */),
-                        false /* mtuSupportsIpv6 */);
-        vpnSnapShot.vpn.mVpnRunner.exitVpnRunner();
-    }
-
-    @Test
-    public void testStartPlatformVpn_underlyingNetworkNotChange() throws Exception {
-        final PlatformVpnSnapshot vpnSnapShot = verifySetupPlatformVpn(
-                createIkeConfig(createIkeConnectInfo(), true /* isMobikeEnabled */));
-        // Trigger update on the same network should not cause underlying network change in NC of
-        // the VPN network
-        vpnSnapShot.nwCb.onAvailable(TEST_NETWORK);
-        vpnSnapShot.nwCb.onCapabilitiesChanged(TEST_NETWORK,
-                new NetworkCapabilities.Builder()
-                        .setSubscriptionIds(Set.of(TEST_SUB_ID))
-                        .build());
-        // Verify setNetwork() called but no underlying network update
-        verify(mIkeSessionWrapper, timeout(TEST_TIMEOUT_MS)).setNetwork(eq(TEST_NETWORK),
-                eq(ESP_IP_VERSION_AUTO) /* ipVersion */,
-                eq(ESP_ENCAP_TYPE_AUTO) /* encapType */,
-                eq(DEFAULT_UDP_PORT_4500_NAT_TIMEOUT_SEC_INT) /* keepaliveDelay */);
-        verify(mMockNetworkAgent, never())
-                .doSetUnderlyingNetworks(any());
-
-        vpnSnapShot.nwCb.onAvailable(TEST_NETWORK_2);
-        vpnSnapShot.nwCb.onCapabilitiesChanged(TEST_NETWORK_2,
-                new NetworkCapabilities.Builder().build());
-
-        // A new network should trigger both setNetwork() and a underlying network update.
-        verify(mIkeSessionWrapper, timeout(TEST_TIMEOUT_MS)).setNetwork(eq(TEST_NETWORK_2),
-                eq(ESP_IP_VERSION_AUTO) /* ipVersion */,
-                eq(ESP_ENCAP_TYPE_AUTO) /* encapType */,
-                eq(DEFAULT_UDP_PORT_4500_NAT_TIMEOUT_SEC_INT) /* keepaliveDelay */);
-        verify(mMockNetworkAgent).doSetUnderlyingNetworks(
-                Collections.singletonList(TEST_NETWORK_2));
-
-        vpnSnapShot.vpn.mVpnRunner.exitVpnRunner();
-    }
-
-    @Test
-    public void testStartPlatformVpnMobility_mobikeEnabled() throws Exception {
-        final PlatformVpnSnapshot vpnSnapShot = verifySetupPlatformVpn(
-                createIkeConfig(createIkeConnectInfo(), true /* isMobikeEnabled */));
-
-        // Set new MTU on a different network
-        final int newMtu = IPV6_MIN_MTU + 1;
-        doReturn(newMtu).when(mTestDeps).calculateVpnMtu(any(), anyInt(), anyInt(), anyBoolean());
-
-        // Mock network loss and verify a cleanup task is scheduled
-        vpnSnapShot.nwCb.onLost(TEST_NETWORK);
-        verify(mExecutor, atLeastOnce()).schedule(any(Runnable.class), anyLong(), any());
-
-        // Mock new network comes up and the cleanup task is cancelled
-        vpnSnapShot.nwCb.onAvailable(TEST_NETWORK_2);
-        verify(mIkeSessionWrapper, never()).setNetwork(any(), anyInt(), anyInt(), anyInt());
-
-        vpnSnapShot.nwCb.onCapabilitiesChanged(TEST_NETWORK_2,
-                new NetworkCapabilities.Builder().build());
-        // Verify MOBIKE is triggered
-        verify(mIkeSessionWrapper, timeout(TEST_TIMEOUT_MS)).setNetwork(eq(TEST_NETWORK_2),
-                eq(ESP_IP_VERSION_AUTO) /* ipVersion */,
-                eq(ESP_ENCAP_TYPE_AUTO) /* encapType */,
-                eq(DEFAULT_UDP_PORT_4500_NAT_TIMEOUT_SEC_INT) /* keepaliveDelay */);
-        // Verify mNetworkCapabilities is updated
-        assertEquals(
-                Collections.singletonList(TEST_NETWORK_2),
-                vpnSnapShot.vpn.mNetworkCapabilities.getUnderlyingNetworks());
-        verify(mMockNetworkAgent)
-                .doSetUnderlyingNetworks(Collections.singletonList(TEST_NETWORK_2));
-
-        // Mock the MOBIKE procedure
-        vpnSnapShot.ikeCb.onIkeSessionConnectionInfoChanged(createIkeConnectInfo_2());
-        vpnSnapShot.childCb.onIpSecTransformsMigrated(
-                createIpSecTransform(), createIpSecTransform());
-
-        verify(mIpSecService).setNetworkForTunnelInterface(
-                eq(TEST_TUNNEL_RESOURCE_ID), eq(TEST_NETWORK_2), anyString());
-
-        // Expect 2 times: one for initial setup and one for MOBIKE
-        verifyApplyTunnelModeTransforms(2);
-
-        // Verify mNetworkAgent is updated
-        verify(mMockNetworkAgent).doSendLinkProperties(argThat(lp -> lp.getMtu() == newMtu));
-        verify(mMockNetworkAgent, never()).unregister();
-        // No further doSetUnderlyingNetworks interaction. The interaction count should stay one.
-        verify(mMockNetworkAgent, times(1)).doSetUnderlyingNetworks(any());
-        vpnSnapShot.vpn.mVpnRunner.exitVpnRunner();
-    }
-
-    @Test
-    public void testStartPlatformVpnMobility_mobikeEnabledMtuDoesNotSupportIpv6() throws Exception {
-        final PlatformVpnSnapshot vpnSnapShot =
-                verifySetupPlatformVpn(
-                        createIkeConfig(createIkeConnectInfo(), true /* isMobikeEnabled */));
-
-        // Set MTU below 1280
-        final int newMtu = IPV6_MIN_MTU - 1;
-        doReturn(newMtu).when(mTestDeps).calculateVpnMtu(any(), anyInt(), anyInt(), anyBoolean());
-
-        // Mock new network available & MOBIKE procedures
-        vpnSnapShot.nwCb.onAvailable(TEST_NETWORK_2);
-        vpnSnapShot.nwCb.onCapabilitiesChanged(TEST_NETWORK_2,
-                new NetworkCapabilities.Builder().build());
-        // Verify mNetworkCapabilities is updated
-        verify(mMockNetworkAgent, timeout(TEST_TIMEOUT_MS))
-                .doSetUnderlyingNetworks(Collections.singletonList(TEST_NETWORK_2));
-        assertEquals(
-                Collections.singletonList(TEST_NETWORK_2),
-                vpnSnapShot.vpn.mNetworkCapabilities.getUnderlyingNetworks());
-
-        vpnSnapShot.ikeCb.onIkeSessionConnectionInfoChanged(createIkeConnectInfo_2());
-        vpnSnapShot.childCb.onIpSecTransformsMigrated(
-                createIpSecTransform(), createIpSecTransform());
-
-        // Verify removal of IPv6 addresses and routes triggers a network agent restart
-        final ArgumentCaptor<LinkProperties> lpCaptor =
-                ArgumentCaptor.forClass(LinkProperties.class);
-        verify(mTestDeps, times(2))
-                .newNetworkAgent(any(), any(), anyString(), any(), lpCaptor.capture(), any(), any(),
-                        any(), any());
-        verify(mMockNetworkAgent).unregister();
-        // mMockNetworkAgent is an old NetworkAgent, so it won't update LinkProperties after
-        // unregistering.
-        verify(mMockNetworkAgent, never()).doSendLinkProperties(any());
-
-        final LinkProperties lp = lpCaptor.getValue();
-
-        for (LinkAddress addr : lp.getLinkAddresses()) {
-            if (addr.isIpv6()) {
-                fail("IPv6 address found on VPN with MTU < IPv6 minimum MTU");
-            }
-        }
-
-        for (InetAddress dnsAddr : lp.getDnsServers()) {
-            if (dnsAddr instanceof Inet6Address) {
-                fail("IPv6 DNS server found on VPN with MTU < IPv6 minimum MTU");
-            }
-        }
-
-        for (RouteInfo routeInfo : lp.getRoutes()) {
-            if (routeInfo.getDestinationLinkAddress().isIpv6()
-                    && !routeInfo.isIPv6UnreachableDefault()) {
-                fail("IPv6 route found on VPN with MTU < IPv6 minimum MTU");
-            }
-        }
-
-        assertEquals(newMtu, lp.getMtu());
-
-        vpnSnapShot.vpn.mVpnRunner.exitVpnRunner();
-    }
-
-    @Test
-    public void testStartPlatformVpnReestablishes_mobikeDisabled() throws Exception {
-        final PlatformVpnSnapshot vpnSnapShot = verifySetupPlatformVpn(
-                createIkeConfig(createIkeConnectInfo(), false /* isMobikeEnabled */));
-
-        // Forget the first IKE creation to be prepared to capture callbacks of the second
-        // IKE session
-        resetIkev2SessionCreator(mock(Vpn.IkeSessionWrapper.class));
-
-        // Mock network switch
-        vpnSnapShot.nwCb.onLost(TEST_NETWORK);
-        vpnSnapShot.nwCb.onAvailable(TEST_NETWORK_2);
-        // The old IKE Session will not be killed until receiving network capabilities change.
-        verify(mIkeSessionWrapper, never()).kill();
-
-        vpnSnapShot.nwCb.onCapabilitiesChanged(
-                TEST_NETWORK_2, new NetworkCapabilities.Builder().build());
-        // Verify the old IKE Session is killed
-        verify(mIkeSessionWrapper, timeout(TEST_TIMEOUT_MS)).kill();
-
-        // Capture callbacks of the new IKE Session
-        final Pair<IkeSessionCallback, ChildSessionCallback> cbPair =
-                verifyCreateIkeAndCaptureCbs();
-        final IkeSessionCallback ikeCb = cbPair.first;
-        final ChildSessionCallback childCb = cbPair.second;
-
-        // Mock the IKE Session setup
-        ikeCb.onOpened(createIkeConfig(createIkeConnectInfo_2(), false /* isMobikeEnabled */));
-
-        childCb.onIpSecTransformCreated(createIpSecTransform(), IpSecManager.DIRECTION_IN);
-        childCb.onIpSecTransformCreated(createIpSecTransform(), IpSecManager.DIRECTION_OUT);
-        childCb.onOpened(createChildConfig());
-
-        // Expect 2 times since there have been two Session setups
-        verifyApplyTunnelModeTransforms(2);
-
-        // Verify mNetworkCapabilities and mNetworkAgent are updated
-        assertEquals(
-                Collections.singletonList(TEST_NETWORK_2),
-                vpnSnapShot.vpn.mNetworkCapabilities.getUnderlyingNetworks());
-        verify(mMockNetworkAgent)
-                .doSetUnderlyingNetworks(Collections.singletonList(TEST_NETWORK_2));
-
-        vpnSnapShot.vpn.mVpnRunner.exitVpnRunner();
-    }
-
-    private String getDump(@NonNull final Vpn vpn) {
-        final StringWriter sw = new StringWriter();
-        final IndentingPrintWriter writer = new IndentingPrintWriter(sw, "");
-        vpn.dump(writer);
-        writer.flush();
-        return sw.toString();
-    }
-
-    private int countMatches(@NonNull final Pattern regexp, @NonNull final String string) {
-        final Matcher m = regexp.matcher(string);
-        int i = 0;
-        while (m.find()) ++i;
-        return i;
-    }
-
-    @Test
-    public void testNCEventChanges() throws Exception {
-        final NetworkCapabilities.Builder ncBuilder = new NetworkCapabilities.Builder()
-                .addTransportType(TRANSPORT_CELLULAR)
-                .addCapability(NET_CAPABILITY_INTERNET)
-                .addCapability(NET_CAPABILITY_NOT_RESTRICTED)
-                .setLinkDownstreamBandwidthKbps(1000)
-                .setLinkUpstreamBandwidthKbps(500);
-
-        final Ikev2VpnProfile ikeProfile =
-                new Ikev2VpnProfile.Builder(TEST_VPN_SERVER, TEST_VPN_IDENTITY)
-                        .setAuthPsk(TEST_VPN_PSK)
-                        .setBypassable(true /* isBypassable */)
-                        .setAutomaticNattKeepaliveTimerEnabled(true)
-                        .setAutomaticIpVersionSelectionEnabled(true)
-                        .build();
-
-        final PlatformVpnSnapshot vpnSnapShot =
-                verifySetupPlatformVpn(ikeProfile.toVpnProfile(),
-                        createIkeConfig(createIkeConnectInfo(), true /* isMobikeEnabled */),
-                        ncBuilder.build(), false /* mtuSupportsIpv6 */,
-                        true /* areLongLivedTcpConnectionsExpensive */);
-
-        // Calls to onCapabilitiesChanged will be thrown to the executor for execution ; by
-        // default this will incur a 10ms delay before it's executed, messing with the timing
-        // of the log and having the checks for counts in equals() below flake.
-        mExecutor.executeDirect = true;
-
-        // First nc changed triggered by verifySetupPlatformVpn
-        final Pattern pattern = Pattern.compile("Cap changed from", Pattern.MULTILINE);
-        final String stage1 = getDump(vpnSnapShot.vpn);
-        assertEquals(1, countMatches(pattern, stage1));
-
-        vpnSnapShot.nwCb.onCapabilitiesChanged(TEST_NETWORK, ncBuilder.build());
-        final String stage2 = getDump(vpnSnapShot.vpn);
-        // Was the same caps, there should still be only 1 match
-        assertEquals(1, countMatches(pattern, stage2));
-
-        ncBuilder.setLinkDownstreamBandwidthKbps(1200)
-                .setLinkUpstreamBandwidthKbps(300);
-        vpnSnapShot.nwCb.onCapabilitiesChanged(TEST_NETWORK, ncBuilder.build());
-        final String stage3 = getDump(vpnSnapShot.vpn);
-        // Was not an important change, should not be logged, still only 1 match
-        assertEquals(1, countMatches(pattern, stage3));
-
-        ncBuilder.addCapability(NET_CAPABILITY_TEMPORARILY_NOT_METERED);
-        vpnSnapShot.nwCb.onCapabilitiesChanged(TEST_NETWORK, ncBuilder.build());
-        final String stage4 = getDump(vpnSnapShot.vpn);
-        // Change to caps is important, should cause a new match
-        assertEquals(2, countMatches(pattern, stage4));
-
-        ncBuilder.removeCapability(NET_CAPABILITY_TEMPORARILY_NOT_METERED);
-        ncBuilder.setLinkDownstreamBandwidthKbps(600);
-        vpnSnapShot.nwCb.onCapabilitiesChanged(TEST_NETWORK, ncBuilder.build());
-        final String stage5 = getDump(vpnSnapShot.vpn);
-        // Change to caps is important, should cause a new match even with the unimportant change
-        assertEquals(3, countMatches(pattern, stage5));
-    }
-    // TODO : beef up event logs tests
-
-    private void verifyHandlingNetworkLoss(PlatformVpnSnapshot vpnSnapShot) throws Exception {
-        // Forget the #sendLinkProperties during first setup.
-        reset(mMockNetworkAgent);
-
-        // Mock network loss
-        vpnSnapShot.nwCb.onLost(TEST_NETWORK);
-
-        // Mock the grace period expires
-        verify(mExecutor, atLeastOnce()).schedule(any(Runnable.class), anyLong(), any());
-
-        final ArgumentCaptor<LinkProperties> lpCaptor =
-                ArgumentCaptor.forClass(LinkProperties.class);
-        verify(mMockNetworkAgent, timeout(TEST_TIMEOUT_MS))
-                .doSendLinkProperties(lpCaptor.capture());
-        final LinkProperties lp = lpCaptor.getValue();
-
-        assertNull(lp.getInterfaceName());
-        final List<RouteInfo> expectedRoutes = Arrays.asList(
-                new RouteInfo(new IpPrefix(Inet4Address.ANY, 0), null /* gateway */,
-                        null /* iface */, RTN_UNREACHABLE),
-                new RouteInfo(new IpPrefix(Inet6Address.ANY, 0), null /* gateway */,
-                        null /* iface */, RTN_UNREACHABLE));
-        assertEquals(expectedRoutes, lp.getRoutes());
-
-        verify(mMockNetworkAgent, timeout(TEST_TIMEOUT_MS)).unregister();
-    }
-
-    @Test
-    public void testStartPlatformVpnHandlesNetworkLoss_mobikeEnabled() throws Exception {
-        final PlatformVpnSnapshot vpnSnapShot = verifySetupPlatformVpn(
-                createIkeConfig(createIkeConnectInfo(), true /* isMobikeEnabled */));
-        verifyHandlingNetworkLoss(vpnSnapShot);
-    }
-
-    @Test
-    public void testStartPlatformVpnHandlesNetworkLoss_mobikeDisabled() throws Exception {
-        final PlatformVpnSnapshot vpnSnapShot = verifySetupPlatformVpn(
-                createIkeConfig(createIkeConnectInfo(), false /* isMobikeEnabled */));
-        verifyHandlingNetworkLoss(vpnSnapShot);
-    }
-
-    private ConnectivityDiagnosticsCallback getConnectivityDiagCallback() {
-        final ArgumentCaptor<ConnectivityDiagnosticsCallback> cdcCaptor =
-                ArgumentCaptor.forClass(ConnectivityDiagnosticsCallback.class);
-        verify(mCdm).registerConnectivityDiagnosticsCallback(
-                any(), any(), cdcCaptor.capture());
-        return cdcCaptor.getValue();
-    }
-
-    private DataStallReport createDataStallReport() {
-        return new DataStallReport(TEST_NETWORK, 1234 /* reportTimestamp */,
-                1 /* detectionMethod */, new LinkProperties(), new NetworkCapabilities(),
-                new PersistableBundle());
-    }
-
-    private void verifyMobikeTriggered(List<Network> expected, int retryIndex) {
-        // Verify retry is scheduled
-        final long expectedDelayMs = mTestDeps.getValidationFailRecoveryMs(retryIndex);
-        final ArgumentCaptor<Long> delayCaptor = ArgumentCaptor.forClass(Long.class);
-        verify(mExecutor, times(retryIndex + 1)).schedule(
-                any(Runnable.class), delayCaptor.capture(), eq(TimeUnit.MILLISECONDS));
-        final List<Long> delays = delayCaptor.getAllValues();
-        assertEquals(expectedDelayMs, (long) delays.get(delays.size() - 1));
-
-        final ArgumentCaptor<Network> networkCaptor = ArgumentCaptor.forClass(Network.class);
-        verify(mIkeSessionWrapper, timeout(TEST_TIMEOUT_MS + expectedDelayMs))
-                .setNetwork(networkCaptor.capture(), anyInt() /* ipVersion */,
-                        anyInt() /* encapType */, anyInt() /* keepaliveDelay */);
-        assertEquals(expected, Collections.singletonList(networkCaptor.getValue()));
-    }
-
-    @Test
-    public void testDataStallInIkev2VpnMobikeDisabled() throws Exception {
-        final PlatformVpnSnapshot vpnSnapShot = verifySetupPlatformVpn(
-                createIkeConfig(createIkeConnectInfo(), false /* isMobikeEnabled */));
-
-        doReturn(TEST_NETWORK).when(mMockNetworkAgent).getNetwork();
-        ((Vpn.IkeV2VpnRunner) vpnSnapShot.vpn.mVpnRunner).onValidationStatus(
-                NetworkAgent.VALIDATION_STATUS_NOT_VALID);
-
-        // Should not trigger MOBIKE if MOBIKE is not enabled
-        verify(mIkeSessionWrapper, never()).setNetwork(any() /* network */,
-                anyInt() /* ipVersion */, anyInt() /* encapType */, anyInt() /* keepaliveDelay */);
-    }
-
-    @Test
-    public void testDataStallInIkev2VpnRecoveredByMobike() throws Exception {
-        final PlatformVpnSnapshot vpnSnapShot = verifySetupPlatformVpn(
-                createIkeConfig(createIkeConnectInfo(), true /* isMobikeEnabled */));
-
-        doReturn(TEST_NETWORK).when(mMockNetworkAgent).getNetwork();
-        ((Vpn.IkeV2VpnRunner) vpnSnapShot.vpn.mVpnRunner).onValidationStatus(
-                NetworkAgent.VALIDATION_STATUS_NOT_VALID);
-        // Verify MOBIKE is triggered
-        verifyMobikeTriggered(vpnSnapShot.vpn.mNetworkCapabilities.getUnderlyingNetworks(),
-                0 /* retryIndex */);
-        // Validation failure on VPN network should trigger a re-evaluation request for the
-        // underlying network.
-        verify(mConnectivityManager).reportNetworkConnectivity(TEST_NETWORK, false);
-
-        reset(mIkev2SessionCreator);
-        reset(mExecutor);
-
-        // Send validation status update.
-        // Recovered and get network validated. It should not trigger the ike session reset.
-        ((Vpn.IkeV2VpnRunner) vpnSnapShot.vpn.mVpnRunner).onValidationStatus(
-                NetworkAgent.VALIDATION_STATUS_VALID);
-        // Verify that the retry count is reset. The mValidationFailRetryCount will not be reset
-        // until the executor finishes the execute() call, so wait until the all tasks are executed.
-        waitForIdleSerialExecutor(mExecutor, TEST_TIMEOUT_MS);
-        assertEquals(0,
-                ((Vpn.IkeV2VpnRunner) vpnSnapShot.vpn.mVpnRunner).mValidationFailRetryCount);
-        verify(mIkev2SessionCreator, never()).createIkeSession(
-                any(), any(), any(), any(), any(), any());
-
-        reset(mIkeSessionWrapper);
-        reset(mExecutor);
-
-        // Another validation fail should trigger another reportNetworkConnectivity
-        ((Vpn.IkeV2VpnRunner) vpnSnapShot.vpn.mVpnRunner).onValidationStatus(
-                NetworkAgent.VALIDATION_STATUS_NOT_VALID);
-        verifyMobikeTriggered(vpnSnapShot.vpn.mNetworkCapabilities.getUnderlyingNetworks(),
-                0 /* retryIndex */);
-        verify(mConnectivityManager, times(2)).reportNetworkConnectivity(TEST_NETWORK, false);
-    }
-
-    @Test
-    public void testDataStallInIkev2VpnNotRecoveredByMobike() throws Exception {
-        final PlatformVpnSnapshot vpnSnapShot = verifySetupPlatformVpn(
-                createIkeConfig(createIkeConnectInfo(), true /* isMobikeEnabled */));
-
-        int retry = 0;
-        doReturn(TEST_NETWORK).when(mMockNetworkAgent).getNetwork();
-        ((Vpn.IkeV2VpnRunner) vpnSnapShot.vpn.mVpnRunner).onValidationStatus(
-                NetworkAgent.VALIDATION_STATUS_NOT_VALID);
-        verifyMobikeTriggered(vpnSnapShot.vpn.mNetworkCapabilities.getUnderlyingNetworks(),
-                retry++);
-        // Validation failure on VPN network should trigger a re-evaluation request for the
-        // underlying network.
-        verify(mConnectivityManager).reportNetworkConnectivity(TEST_NETWORK, false);
-        reset(mIkev2SessionCreator);
-
-        // Second validation status update.
-        ((Vpn.IkeV2VpnRunner) vpnSnapShot.vpn.mVpnRunner).onValidationStatus(
-                NetworkAgent.VALIDATION_STATUS_NOT_VALID);
-        verifyMobikeTriggered(vpnSnapShot.vpn.mNetworkCapabilities.getUnderlyingNetworks(),
-                retry++);
-        // Call to reportNetworkConnectivity should only happen once. No further interaction.
-        verify(mConnectivityManager, times(1)).reportNetworkConnectivity(TEST_NETWORK, false);
-
-        // Use real delay to verify reset session will not be performed if there is an existing
-        // recovery for resetting the session.
-        mExecutor.delayMs = TestExecutor.REAL_DELAY;
-        mExecutor.executeDirect = true;
-        // Send validation status update should result in ike session reset.
-        ((Vpn.IkeV2VpnRunner) vpnSnapShot.vpn.mVpnRunner).onValidationStatus(
-                NetworkAgent.VALIDATION_STATUS_NOT_VALID);
-
-        // Verify session reset is scheduled
-        long expectedDelay = mTestDeps.getValidationFailRecoveryMs(retry++);
-        final ArgumentCaptor<Long> delayCaptor = ArgumentCaptor.forClass(Long.class);
-        verify(mExecutor, times(retry)).schedule(any(Runnable.class), delayCaptor.capture(),
-                eq(TimeUnit.MILLISECONDS));
-        final List<Long> delays = delayCaptor.getAllValues();
-        assertEquals(expectedDelay, (long) delays.get(delays.size() - 1));
-        // Call to reportNetworkConnectivity should only happen once. No further interaction.
-        verify(mConnectivityManager, times(1)).reportNetworkConnectivity(TEST_NETWORK, false);
-
-        // Another invalid status reported should not trigger other scheduled recovery.
-        expectedDelay = mTestDeps.getValidationFailRecoveryMs(retry++);
-        ((Vpn.IkeV2VpnRunner) vpnSnapShot.vpn.mVpnRunner).onValidationStatus(
-                NetworkAgent.VALIDATION_STATUS_NOT_VALID);
-        verify(mExecutor, never()).schedule(
-                any(Runnable.class), eq(expectedDelay), eq(TimeUnit.MILLISECONDS));
-
-        // Verify that session being reset
-        verify(mIkev2SessionCreator, timeout(TEST_TIMEOUT_MS + expectedDelay))
-                .createIkeSession(any(), any(), any(), any(), any(), any());
-        // Call to reportNetworkConnectivity should only happen once. No further interaction.
-        verify(mConnectivityManager, times(1)).reportNetworkConnectivity(TEST_NETWORK, false);
-    }
-
-    @Test
-    public void testStartLegacyVpnType() throws Exception {
-        setMockedUsers(PRIMARY_USER);
-        final Vpn vpn = createVpn(PRIMARY_USER.id);
-        final VpnProfile profile = new VpnProfile("testProfile" /* key */);
-
-        profile.type = VpnProfile.TYPE_PPTP;
-        assertThrows(UnsupportedOperationException.class, () -> startLegacyVpn(vpn, profile));
-        profile.type = VpnProfile.TYPE_L2TP_IPSEC_PSK;
-        assertThrows(UnsupportedOperationException.class, () -> startLegacyVpn(vpn, profile));
-    }
-
-    @Test
-    public void testStartLegacyVpnModifyProfile_TypePSK() throws Exception {
-        setMockedUsers(PRIMARY_USER);
-        final Vpn vpn = createVpn(PRIMARY_USER.id);
-        final Ikev2VpnProfile ikev2VpnProfile =
-                new Ikev2VpnProfile.Builder(TEST_VPN_SERVER, TEST_VPN_IDENTITY)
-                        .setAuthPsk(TEST_VPN_PSK)
-                        .build();
-        final VpnProfile profile = ikev2VpnProfile.toVpnProfile();
-
-        startLegacyVpn(vpn, profile);
-        assertEquals(profile, ikev2VpnProfile.toVpnProfile());
-    }
-
-    private void assertTransportInfoMatches(NetworkCapabilities nc, int type) {
-        assertNotNull(nc);
-        VpnTransportInfo ti = (VpnTransportInfo) nc.getTransportInfo();
-        assertNotNull(ti);
-        assertEquals(type, ti.getType());
-    }
-
-    // Make it public and un-final so as to spy it
-    public class TestDeps extends Vpn.Dependencies {
-        TestDeps() {}
-
-        @Override
-        public boolean isCallerSystem() {
-            return true;
-        }
-
-        @Override
-        public PendingIntent getIntentForStatusPanel(Context context) {
-            return null;
-        }
-
-        @Override
-        public ParcelFileDescriptor adoptFd(Vpn vpn, int mtu) {
-            return new ParcelFileDescriptor(new FileDescriptor());
-        }
-
-        @Override
-        public int jniCreate(Vpn vpn, int mtu) {
-            // Pick a random positive number as fd to return.
-            return 345;
-        }
-
-        @Override
-        public String jniGetName(Vpn vpn, int fd) {
-            return TEST_IFACE_NAME;
-        }
-
-        @Override
-        public int jniSetAddresses(Vpn vpn, String interfaze, String addresses) {
-            if (addresses == null) return 0;
-            // Return the number of addresses.
-            return addresses.split(" ").length;
-        }
-
-        @Override
-        public void setBlocking(FileDescriptor fd, boolean blocking) {}
-
-        @Override
-        public DeviceIdleInternal getDeviceIdleInternal() {
-            return mDeviceIdleInternal;
-        }
-
-        @Override
-        public long getValidationFailRecoveryMs(int retryCount) {
-            // Simply return retryCount as the delay seconds for retrying.
-            return retryCount * 100L;
-        }
-
-        @Override
-        public ScheduledThreadPoolExecutor newScheduledThreadPoolExecutor() {
-            return mExecutor;
-        }
-
-        public boolean mIgnoreCallingUidChecks = true;
-        @Override
-        public void verifyCallingUidAndPackage(Context context, String packageName, int userId) {
-            if (!mIgnoreCallingUidChecks) {
-                super.verifyCallingUidAndPackage(context, packageName, userId);
-            }
-        }
-    }
-
-    /**
-     * Mock some methods of vpn object.
-     */
-    private Vpn createVpn(@UserIdInt int userId) {
-        final Context asUserContext = mock(Context.class, AdditionalAnswers.delegatesTo(mContext));
-        doReturn(UserHandle.of(userId)).when(asUserContext).getUser();
-        when(mContext.createContextAsUser(eq(UserHandle.of(userId)), anyInt()))
-                .thenReturn(asUserContext);
-        final TestLooper testLooper = new TestLooper();
-        final Vpn vpn = new Vpn(testLooper.getLooper(), mContext, mTestDeps, mNetService,
-                mNetd, userId, mVpnProfileStore, mSystemServices, mIkev2SessionCreator);
-        verify(mConnectivityManager, times(1)).registerNetworkProvider(argThat(
-                provider -> provider.getName().contains("VpnNetworkProvider")
-        ));
-        return vpn;
-    }
-
-    /**
-     * Populate {@link #mUserManager} with a list of fake users.
-     */
-    private void setMockedUsers(UserInfo... users) {
-        final Map<Integer, UserInfo> userMap = new ArrayMap<>();
-        for (UserInfo user : users) {
-            userMap.put(user.id, user);
-        }
-
-        /**
-         * @see UserManagerService#getUsers(boolean)
-         */
-        doAnswer(invocation -> {
-            final ArrayList<UserInfo> result = new ArrayList<>(users.length);
-            for (UserInfo ui : users) {
-                if (ui.isEnabled() && !ui.partial) {
-                    result.add(ui);
-                }
-            }
-            return result;
-        }).when(mUserManager).getAliveUsers();
-
-        doAnswer(invocation -> {
-            final int id = (int) invocation.getArguments()[0];
-            return userMap.get(id);
-        }).when(mUserManager).getUserInfo(anyInt());
-    }
-
-    /**
-     * Populate {@link #mPackageManager} with a fake packageName-to-UID mapping.
-     */
-    private void setMockedPackages(final Map<String, Integer> packages) {
-        try {
-            doAnswer(invocation -> {
-                final String appName = (String) invocation.getArguments()[0];
-                final int userId = (int) invocation.getArguments()[1];
-                Integer appId = packages.get(appName);
-                if (appId == null) throw new PackageManager.NameNotFoundException(appName);
-                return UserHandle.getUid(userId, appId);
-            }).when(mPackageManager).getPackageUidAsUser(anyString(), anyInt());
-        } catch (Exception e) {
-        }
-    }
-}
diff --git a/tests/unit/java/com/android/server/connectivity/mdns/MdnsAdvertiserTest.kt b/tests/unit/java/com/android/server/connectivity/mdns/MdnsAdvertiserTest.kt
index f753c93..df48f6c 100644
--- a/tests/unit/java/com/android/server/connectivity/mdns/MdnsAdvertiserTest.kt
+++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsAdvertiserTest.kt
@@ -38,6 +38,7 @@
 import com.android.testutils.DevSdkIgnoreRunner
 import com.android.testutils.waitForIdle
 import java.net.NetworkInterface
+import java.time.Duration
 import java.util.Objects
 import java.util.concurrent.CompletableFuture
 import java.util.concurrent.TimeUnit
@@ -285,7 +286,6 @@
 
         postSync { socketCb.onInterfaceDestroyed(TEST_SOCKETKEY_1, mockSocket1) }
         verify(mockInterfaceAdvertiser1).destroyNow()
-        postSync { intAdvCbCaptor.value.onDestroyed(mockSocket1) }
         verify(cb).onOffloadStop(eq(TEST_INTERFACE1), eq(OFFLOAD_SERVICEINFO_NO_SUBTYPE2))
     }
 
@@ -313,9 +313,9 @@
                 eq(thread.looper), any(), intAdvCbCaptor2.capture(), eq(TEST_HOSTNAME), any(), any()
         )
         verify(mockInterfaceAdvertiser1).addService(
-                anyInt(), eq(ALL_NETWORKS_SERVICE_SUBTYPE))
+                anyInt(), eq(ALL_NETWORKS_SERVICE_SUBTYPE), any())
         verify(mockInterfaceAdvertiser2).addService(
-                anyInt(), eq(ALL_NETWORKS_SERVICE_SUBTYPE))
+                anyInt(), eq(ALL_NETWORKS_SERVICE_SUBTYPE), any())
 
         doReturn(false).`when`(mockInterfaceAdvertiser1).isProbing(SERVICE_ID_1)
         postSync { intAdvCbCaptor1.value.onServiceProbingSucceeded(
@@ -363,10 +363,10 @@
         verify(cb).onOffloadStop(eq(TEST_INTERFACE1), eq(OFFLOAD_SERVICEINFO))
         verify(cb).onOffloadStop(eq(TEST_INTERFACE2), eq(OFFLOAD_SERVICEINFO))
 
-        // Interface advertisers call onDestroyed after sending exit announcements
-        postSync { intAdvCbCaptor1.value.onDestroyed(mockSocket1) }
+        // Interface advertisers call onAllServicesRemoved after sending exit announcements
+        postSync { intAdvCbCaptor1.value.onAllServicesRemoved(mockSocket1) }
         verify(socketProvider, never()).unrequestSocket(any())
-        postSync { intAdvCbCaptor2.value.onDestroyed(mockSocket2) }
+        postSync { intAdvCbCaptor2.value.onAllServicesRemoved(mockSocket2) }
         verify(socketProvider).unrequestSocket(socketCb)
     }
 
@@ -489,15 +489,15 @@
                 eq(thread.looper), any(), intAdvCbCaptor.capture(), eq(TEST_HOSTNAME), any(), any()
         )
         verify(mockInterfaceAdvertiser1).addService(eq(SERVICE_ID_1),
-                argThat { it.matches(SERVICE_1) })
+                argThat { it.matches(SERVICE_1) }, any())
         verify(mockInterfaceAdvertiser1).addService(eq(SERVICE_ID_2),
-                argThat { it.matches(expectedRenamed) })
+                argThat { it.matches(expectedRenamed) }, any())
         verify(mockInterfaceAdvertiser1).addService(eq(LONG_SERVICE_ID_1),
-                argThat { it.matches(LONG_SERVICE_1) })
+                argThat { it.matches(LONG_SERVICE_1) }, any())
         verify(mockInterfaceAdvertiser1).addService(eq(LONG_SERVICE_ID_2),
-            argThat { it.matches(expectedLongRenamed) })
+            argThat { it.matches(expectedLongRenamed) }, any())
         verify(mockInterfaceAdvertiser1).addService(eq(CASE_INSENSITIVE_TEST_SERVICE_ID),
-            argThat { it.matches(expectedCaseInsensitiveRenamed) })
+            argThat { it.matches(expectedCaseInsensitiveRenamed) }, any())
 
         doReturn(false).`when`(mockInterfaceAdvertiser1).isProbing(SERVICE_ID_1)
         postSync { intAdvCbCaptor.value.onServiceProbingSucceeded(
@@ -532,7 +532,7 @@
         postSync { socketCb.onSocketCreated(TEST_SOCKETKEY_1, mockSocket1, listOf(TEST_LINKADDR)) }
 
         verify(mockInterfaceAdvertiser1).addService(eq(SERVICE_ID_1),
-                argThat { it.matches(ALL_NETWORKS_SERVICE) })
+                argThat { it.matches(ALL_NETWORKS_SERVICE) }, any())
 
         val updateOptions = MdnsAdvertisingOptions.newBuilder().setIsOnlyUpdate(true).build()
 
@@ -554,7 +554,24 @@
         // Newly created MdnsInterfaceAdvertiser will get addService() call.
         postSync { socketCb.onSocketCreated(TEST_SOCKETKEY_2, mockSocket2, listOf(TEST_LINKADDR2)) }
         verify(mockInterfaceAdvertiser2).addService(eq(SERVICE_ID_1),
-                argThat { it.matches(ALL_NETWORKS_SERVICE_SUBTYPE) })
+                argThat { it.matches(ALL_NETWORKS_SERVICE_SUBTYPE) }, any())
+    }
+
+    @Test
+    fun testAddOrUpdateService_customTtl_registeredSuccess() {
+        val advertiser = MdnsAdvertiser(
+                thread.looper, socketProvider, cb, mockDeps, sharedlog, flags, context)
+        val updateOptions =
+                MdnsAdvertisingOptions.newBuilder().setTtl(Duration.ofSeconds(30)).build()
+
+        postSync { advertiser.addOrUpdateService(SERVICE_ID_1, ALL_NETWORKS_SERVICE,
+                updateOptions, TEST_CLIENT_UID_1) }
+
+        val socketCbCaptor = ArgumentCaptor.forClass(SocketCallback::class.java)
+        verify(socketProvider).requestSocket(eq(null), socketCbCaptor.capture())
+        val socketCb = socketCbCaptor.value
+        postSync { socketCb.onSocketCreated(TEST_SOCKETKEY_1, mockSocket1, listOf(TEST_LINKADDR)) }
+        verify(mockInterfaceAdvertiser1).addService(eq(SERVICE_ID_1), any(), eq(updateOptions))
     }
 
     @Test
diff --git a/tests/unit/java/com/android/server/connectivity/mdns/MdnsDiscoveryManagerTests.java b/tests/unit/java/com/android/server/connectivity/mdns/MdnsDiscoveryManagerTests.java
index 5251e2a..b5c0132 100644
--- a/tests/unit/java/com/android/server/connectivity/mdns/MdnsDiscoveryManagerTests.java
+++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsDiscoveryManagerTests.java
@@ -18,6 +18,8 @@
 
 import static com.android.testutils.DevSdkIgnoreRuleKt.SC_V2;
 
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.fail;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.doReturn;
 import static org.mockito.Mockito.eq;
@@ -65,8 +67,9 @@
     private static final String SERVICE_TYPE_2 = "_test._tcp.local";
     private static final Network NETWORK_1 = Mockito.mock(Network.class);
     private static final Network NETWORK_2 = Mockito.mock(Network.class);
+    private static final int INTERFACE_INDEX_NULL_NETWORK = 123;
     private static final SocketKey SOCKET_KEY_NULL_NETWORK =
-            new SocketKey(null /* network */, 999 /* interfaceIndex */);
+            new SocketKey(null /* network */, INTERFACE_INDEX_NULL_NETWORK);
     private static final SocketKey SOCKET_KEY_NETWORK_1 =
             new SocketKey(NETWORK_1, 998 /* interfaceIndex */);
     private static final SocketKey SOCKET_KEY_NETWORK_2 =
@@ -97,6 +100,8 @@
     private HandlerThread thread;
     private Handler handler;
 
+    private int createdServiceTypeClientCount;
+
     @Before
     public void setUp() {
         MockitoAnnotations.initMocks(this);
@@ -106,11 +111,13 @@
         handler = new Handler(thread.getLooper());
         doReturn(thread.getLooper()).when(socketClient).getLooper();
         doReturn(true).when(socketClient).supportsRequestingSpecificNetworks();
+        createdServiceTypeClientCount = 0;
         discoveryManager = new MdnsDiscoveryManager(executorProvider, socketClient,
                 sharedLog, MdnsFeatureFlags.newBuilder().build()) {
                     @Override
                     MdnsServiceTypeClient createServiceTypeClient(@NonNull String serviceType,
                             @NonNull SocketKey socketKey) {
+                        createdServiceTypeClientCount++;
                         final Pair<String, SocketKey> perSocketServiceType =
                                 Pair.create(serviceType, socketKey);
                         if (perSocketServiceType.equals(PER_SOCKET_SERVICE_TYPE_1_NULL_NETWORK)) {
@@ -128,6 +135,7 @@
                                 PER_SOCKET_SERVICE_TYPE_2_NETWORK_2)) {
                             return mockServiceTypeClientType2Network2;
                         }
+                        fail("Unexpected perSocketServiceType: " + perSocketServiceType);
                         return null;
                     }
                 };
@@ -324,7 +332,6 @@
 
         // Receive a response, it should be processed on the client.
         final MdnsPacket response = createMdnsPacket(SERVICE_TYPE_1);
-        final int ifIndex = 1;
         runOnHandler(() -> discoveryManager.onResponseReceived(response, SOCKET_KEY_NULL_NETWORK));
         verify(mockServiceTypeClientType1NullNetwork).processResponse(
                 response, SOCKET_KEY_NULL_NETWORK);
@@ -350,6 +357,39 @@
         verify(socketClient, never()).stopDiscovery();
     }
 
+    @Test
+    public void testInterfaceIndexRequested_OnlyUsesSelectedInterface() throws IOException {
+        final MdnsSearchOptions searchOptions =
+                MdnsSearchOptions.newBuilder()
+                        .setNetwork(null /* network */)
+                        .setInterfaceIndex(INTERFACE_INDEX_NULL_NETWORK)
+                        .build();
+
+        final SocketCreationCallback callback = expectSocketCreationCallback(
+                SERVICE_TYPE_1, mockListenerOne, searchOptions);
+        final SocketKey unusedIfaceKey = new SocketKey(null, INTERFACE_INDEX_NULL_NETWORK + 1);
+        final SocketKey matchingIfaceWithNetworkKey =
+                new SocketKey(Mockito.mock(Network.class), INTERFACE_INDEX_NULL_NETWORK);
+        runOnHandler(() -> {
+            callback.onSocketCreated(unusedIfaceKey);
+            callback.onSocketCreated(matchingIfaceWithNetworkKey);
+            callback.onSocketCreated(SOCKET_KEY_NULL_NETWORK);
+            callback.onSocketCreated(SOCKET_KEY_NETWORK_1);
+        });
+        // Only the client for INTERFACE_INDEX_NULL_NETWORK is created
+        verify(mockServiceTypeClientType1NullNetwork).startSendAndReceive(
+                mockListenerOne, searchOptions);
+        assertEquals(1, createdServiceTypeClientCount);
+
+        runOnHandler(() -> {
+            callback.onSocketDestroyed(SOCKET_KEY_NETWORK_1);
+            callback.onSocketDestroyed(SOCKET_KEY_NULL_NETWORK);
+            callback.onSocketDestroyed(matchingIfaceWithNetworkKey);
+            callback.onSocketDestroyed(unusedIfaceKey);
+        });
+        verify(mockServiceTypeClientType1NullNetwork).notifySocketDestroyed();
+    }
+
     private MdnsPacket createMdnsPacket(String serviceType) {
         final String[] type = TextUtils.split(serviceType, "\\.");
         final ArrayList<String> name = new ArrayList<>(type.length + 1);
diff --git a/tests/unit/java/com/android/server/connectivity/mdns/MdnsInterfaceAdvertiserTest.kt b/tests/unit/java/com/android/server/connectivity/mdns/MdnsInterfaceAdvertiserTest.kt
index 0637ad1..629ac67 100644
--- a/tests/unit/java/com/android/server/connectivity/mdns/MdnsInterfaceAdvertiserTest.kt
+++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsInterfaceAdvertiserTest.kt
@@ -18,7 +18,6 @@
 
 import android.net.InetAddresses.parseNumericAddress
 import android.net.LinkAddress
-import android.net.nsd.NsdManager
 import android.net.nsd.NsdServiceInfo
 import android.os.Build
 import android.os.HandlerThread
@@ -55,6 +54,7 @@
 import org.mockito.Mockito.never
 import org.mockito.Mockito.times
 import org.mockito.Mockito.verify
+import org.mockito.Mockito.inOrder
 
 private const val LOG_TAG = "testlogtag"
 private const val TIMEOUT_MS = 10_000L
@@ -65,6 +65,7 @@
 
 private const val TEST_SERVICE_ID_1 = 42
 private const val TEST_SERVICE_ID_DUPLICATE = 43
+private const val TEST_SERVICE_ID_2 = 44
 private val TEST_SERVICE_1 = NsdServiceInfo().apply {
     serviceType = "_testservice._tcp"
     serviceName = "MyTestService"
@@ -78,6 +79,13 @@
     port = 12345
 }
 
+private val TEST_SERVICE_1_CUSTOM_HOST = NsdServiceInfo().apply {
+    serviceType = "_testservice._tcp"
+    serviceName = "MyTestService"
+    hostname = "MyTestHost"
+    port = 12345
+}
+
 @RunWith(DevSdkIgnoreRunner::class)
 @IgnoreUpTo(Build.VERSION_CODES.S_V2)
 class MdnsInterfaceAdvertiserTest {
@@ -132,7 +140,7 @@
             knownServices.add(inv.getArgument(0))
 
             -1
-        }.`when`(repository).addService(anyInt(), any())
+        }.`when`(repository).addService(anyInt(), any(), any())
         doAnswer { inv ->
             knownServices.remove(inv.getArgument(0))
             null
@@ -179,7 +187,94 @@
         // Exit announcements finish: the advertiser has no left service and destroys itself
         announceCb.onFinished(testExitInfo)
         thread.waitForIdle(TIMEOUT_MS)
-        verify(cb).onDestroyed(socket)
+        verify(cb).onAllServicesRemoved(socket)
+    }
+
+    @Test
+    fun testAddRemoveServiceWithCustomHost_restartProbingForProbingServices() {
+        val customHost1 = NsdServiceInfo().apply {
+            hostname = "MyTestHost"
+            hostAddresses = listOf(
+                    parseNumericAddress("192.0.2.23"),
+                    parseNumericAddress("2001:db8::1"))
+        }
+        addServiceAndFinishProbing(TEST_SERVICE_ID_1, customHost1)
+        addServiceAndFinishProbing(TEST_SERVICE_ID_2, TEST_SERVICE_1_CUSTOM_HOST)
+        repository.setServiceProbing(TEST_SERVICE_ID_2)
+        val probingInfo = mock(ProbingInfo::class.java)
+        doReturn("MyTestHost")
+                .`when`(repository).getHostnameForServiceId(TEST_SERVICE_ID_1)
+        doReturn(TEST_SERVICE_ID_2).`when`(probingInfo).serviceId
+        doReturn(listOf(probingInfo))
+                .`when`(repository).restartProbingForHostname("MyTestHost")
+        val inOrder = inOrder(prober, announcer)
+
+        // Remove the custom host: the custom host's announcement is stopped and the probing
+        // services which use that hostname are re-announced.
+        advertiser.removeService(TEST_SERVICE_ID_1)
+
+        inOrder.verify(prober).stop(TEST_SERVICE_ID_1)
+        inOrder.verify(announcer).stop(TEST_SERVICE_ID_1)
+        inOrder.verify(prober).stop(TEST_SERVICE_ID_2)
+        inOrder.verify(prober).startProbing(probingInfo)
+    }
+
+    @Test
+    fun testAddRemoveServiceWithCustomHost_restartAnnouncingForProbedServices() {
+        val customHost1 = NsdServiceInfo().apply {
+            hostname = "MyTestHost"
+            hostAddresses = listOf(
+                    parseNumericAddress("192.0.2.23"),
+                    parseNumericAddress("2001:db8::1"))
+        }
+        addServiceAndFinishProbing(TEST_SERVICE_ID_1, customHost1)
+        val announcementInfo =
+                addServiceAndFinishProbing(TEST_SERVICE_ID_2, TEST_SERVICE_1_CUSTOM_HOST)
+        doReturn("MyTestHost")
+                .`when`(repository).getHostnameForServiceId(TEST_SERVICE_ID_1)
+        doReturn(listOf(announcementInfo))
+                .`when`(repository).restartAnnouncingForHostname("MyTestHost")
+        val inOrder = inOrder(prober, announcer)
+
+        // Remove the custom host: the custom host's announcement is stopped and the probed services
+        // which use that hostname are re-announced.
+        advertiser.removeService(TEST_SERVICE_ID_1)
+
+        inOrder.verify(prober).stop(TEST_SERVICE_ID_1)
+        inOrder.verify(announcer).stop(TEST_SERVICE_ID_1)
+        inOrder.verify(announcer).stop(TEST_SERVICE_ID_2)
+        inOrder.verify(announcer).startSending(TEST_SERVICE_ID_2, announcementInfo, 0L /* initialDelayMs */)
+    }
+
+    @Test
+    fun testAddMoreAddressesForCustomHost_restartAnnouncingForProbedServices() {
+        val customHost = NsdServiceInfo().apply {
+            hostname = "MyTestHost"
+            hostAddresses = listOf(
+                parseNumericAddress("192.0.2.23"),
+                parseNumericAddress("2001:db8::1"))
+        }
+        doReturn("MyTestHost")
+            .`when`(repository).getHostnameForServiceId(TEST_SERVICE_ID_1)
+        doReturn("MyTestHost")
+            .`when`(repository).getHostnameForServiceId(TEST_SERVICE_ID_2)
+        val announcementInfo1 =
+            addServiceAndFinishProbing(TEST_SERVICE_ID_1, TEST_SERVICE_1_CUSTOM_HOST)
+
+        val probingInfo2 = addServiceAndStartProbing(TEST_SERVICE_ID_2, customHost)
+        val announcementInfo2 = AnnouncementInfo(TEST_SERVICE_ID_2, emptyList(), emptyList())
+        doReturn(announcementInfo2).`when`(repository).onProbingSucceeded(probingInfo2)
+        doReturn(listOf(announcementInfo1, announcementInfo2))
+            .`when`(repository).restartAnnouncingForHostname("MyTestHost")
+        probeCb.onFinished(probingInfo2)
+
+        val inOrder = inOrder(prober, announcer)
+
+        inOrder.verify(announcer)
+            .startSending(TEST_SERVICE_ID_2, announcementInfo2, 0L /* initialDelayMs */)
+        inOrder.verify(announcer).stop(TEST_SERVICE_ID_1)
+        inOrder.verify(announcer)
+            .startSending(TEST_SERVICE_ID_1, announcementInfo1, 0L /* initialDelayMs */)
     }
 
     @Test
@@ -403,9 +498,10 @@
     @Test
     fun testReplaceExitingService() {
         doReturn(TEST_SERVICE_ID_DUPLICATE).`when`(repository)
-                .addService(eq(TEST_SERVICE_ID_DUPLICATE), any())
-        advertiser.addService(TEST_SERVICE_ID_DUPLICATE, TEST_SERVICE_1_SUBTYPE)
-        verify(repository).addService(eq(TEST_SERVICE_ID_DUPLICATE), any())
+                .addService(eq(TEST_SERVICE_ID_DUPLICATE), any(), any())
+        advertiser.addService(TEST_SERVICE_ID_DUPLICATE, TEST_SERVICE_1_SUBTYPE,
+                MdnsAdvertisingOptions.getDefaultOptions())
+        verify(repository).addService(eq(TEST_SERVICE_ID_DUPLICATE), any(), any())
         verify(announcer).stop(TEST_SERVICE_ID_DUPLICATE)
         verify(prober).startProbing(any())
     }
@@ -413,7 +509,7 @@
     @Test
     fun testUpdateExistingService() {
         doReturn(TEST_SERVICE_ID_DUPLICATE).`when`(repository)
-                .addService(eq(TEST_SERVICE_ID_DUPLICATE), any())
+                .addService(eq(TEST_SERVICE_ID_DUPLICATE), any(), any())
         val subTypes = setOf("_sub")
         advertiser.updateService(TEST_SERVICE_ID_DUPLICATE, subTypes)
         verify(repository).updateService(eq(TEST_SERVICE_ID_DUPLICATE), any())
@@ -421,18 +517,25 @@
         verify(prober, never()).startProbing(any())
     }
 
-    private fun addServiceAndFinishProbing(serviceId: Int, serviceInfo: NsdServiceInfo):
-            AnnouncementInfo {
+    private fun addServiceAndStartProbing(serviceId: Int, serviceInfo: NsdServiceInfo):
+            ProbingInfo {
         val testProbingInfo = mock(ProbingInfo::class.java)
         doReturn(serviceId).`when`(testProbingInfo).serviceId
         doReturn(testProbingInfo).`when`(repository).setServiceProbing(serviceId)
 
-        advertiser.addService(serviceId, serviceInfo)
-        verify(repository).addService(serviceId, serviceInfo)
+        advertiser.addService(serviceId, serviceInfo, MdnsAdvertisingOptions.getDefaultOptions())
+        verify(repository).addService(serviceId, serviceInfo, null /* ttl */)
         verify(prober).startProbing(testProbingInfo)
 
+        return testProbingInfo
+    }
+
+    private fun addServiceAndFinishProbing(serviceId: Int, serviceInfo: NsdServiceInfo):
+            AnnouncementInfo {
+        val testProbingInfo = addServiceAndStartProbing(serviceId, serviceInfo)
+
         // Simulate probing success: continues to announcing
-        val testAnnouncementInfo = mock(AnnouncementInfo::class.java)
+        val testAnnouncementInfo = AnnouncementInfo(serviceId, emptyList(), emptyList())
         doReturn(testAnnouncementInfo).`when`(repository).onProbingSucceeded(testProbingInfo)
         probeCb.onFinished(testProbingInfo)
         return testAnnouncementInfo
diff --git a/tests/unit/java/com/android/server/connectivity/mdns/MdnsMultinetworkSocketClientTest.java b/tests/unit/java/com/android/server/connectivity/mdns/MdnsMultinetworkSocketClientTest.java
index 9474464..fb3d183 100644
--- a/tests/unit/java/com/android/server/connectivity/mdns/MdnsMultinetworkSocketClientTest.java
+++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsMultinetworkSocketClientTest.java
@@ -23,6 +23,7 @@
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.doReturn;
 import static org.mockito.Mockito.eq;
+import static org.mockito.Mockito.inOrder;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.timeout;
@@ -47,6 +48,7 @@
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.mockito.ArgumentCaptor;
+import org.mockito.InOrder;
 import org.mockito.Mock;
 import org.mockito.MockitoAnnotations;
 
@@ -55,6 +57,7 @@
 import java.net.DatagramPacket;
 import java.net.NetworkInterface;
 import java.net.SocketException;
+import java.util.ArrayList;
 import java.util.List;
 
 @RunWith(DevSdkIgnoreRunner.class)
@@ -154,7 +157,7 @@
         verify(mSocketCreationCallback).onSocketCreated(tetherSocketKey2);
 
         // Send packet to IPv4 with mSocketKey and verify sending has been called.
-        mSocketClient.sendPacketRequestingMulticastResponse(ipv4Packet, mSocketKey,
+        mSocketClient.sendPacketRequestingMulticastResponse(List.of(ipv4Packet), mSocketKey,
                 false /* onlyUseIpv6OnIpv6OnlyNetworks */);
         HandlerUtils.waitForIdle(mHandler, DEFAULT_TIMEOUT);
         verify(mSocket).send(ipv4Packet);
@@ -162,7 +165,7 @@
         verify(tetherIfaceSock2, never()).send(any());
 
         // Send packet to IPv4 with onlyUseIpv6OnIpv6OnlyNetworks = true, the packet will be sent.
-        mSocketClient.sendPacketRequestingMulticastResponse(ipv4Packet, mSocketKey,
+        mSocketClient.sendPacketRequestingMulticastResponse(List.of(ipv4Packet), mSocketKey,
                 true /* onlyUseIpv6OnIpv6OnlyNetworks */);
         HandlerUtils.waitForIdle(mHandler, DEFAULT_TIMEOUT);
         verify(mSocket, times(2)).send(ipv4Packet);
@@ -170,7 +173,7 @@
         verify(tetherIfaceSock2, never()).send(any());
 
         // Send packet to IPv6 with tetherSocketKey1 and verify sending has been called.
-        mSocketClient.sendPacketRequestingMulticastResponse(ipv6Packet, tetherSocketKey1,
+        mSocketClient.sendPacketRequestingMulticastResponse(List.of(ipv6Packet), tetherSocketKey1,
                 false /* onlyUseIpv6OnIpv6OnlyNetworks */);
         HandlerUtils.waitForIdle(mHandler, DEFAULT_TIMEOUT);
         verify(mSocket, never()).send(ipv6Packet);
@@ -180,7 +183,7 @@
         // Send packet to IPv6 with onlyUseIpv6OnIpv6OnlyNetworks = true, the packet will not be
         // sent. Therefore, the tetherIfaceSock1.send() and tetherIfaceSock2.send() are still be
         // called once.
-        mSocketClient.sendPacketRequestingMulticastResponse(ipv6Packet, tetherSocketKey1,
+        mSocketClient.sendPacketRequestingMulticastResponse(List.of(ipv6Packet), tetherSocketKey1,
                 true /* onlyUseIpv6OnIpv6OnlyNetworks */);
         HandlerUtils.waitForIdle(mHandler, DEFAULT_TIMEOUT);
         verify(mSocket, never()).send(ipv6Packet);
@@ -266,7 +269,7 @@
         verify(mSocketCreationCallback).onSocketCreated(socketKey3);
 
         // Send IPv4 packet on the mSocketKey and verify sending has been called.
-        mSocketClient.sendPacketRequestingMulticastResponse(ipv4Packet, mSocketKey,
+        mSocketClient.sendPacketRequestingMulticastResponse(List.of(ipv4Packet), mSocketKey,
                 false /* onlyUseIpv6OnIpv6OnlyNetworks */);
         HandlerUtils.waitForIdle(mHandler, DEFAULT_TIMEOUT);
         verify(mSocket).send(ipv4Packet);
@@ -295,7 +298,7 @@
         verify(socketCreationCb2).onSocketCreated(socketKey3);
 
         // Send IPv4 packet on socket2 and verify sending to the socket2 only.
-        mSocketClient.sendPacketRequestingMulticastResponse(ipv4Packet, socketKey2,
+        mSocketClient.sendPacketRequestingMulticastResponse(List.of(ipv4Packet), socketKey2,
                 false /* onlyUseIpv6OnIpv6OnlyNetworks */);
         HandlerUtils.waitForIdle(mHandler, DEFAULT_TIMEOUT);
         // ipv4Packet still sent only once on mSocket: times(1) matches the packet sent earlier on
@@ -309,7 +312,7 @@
         verify(mProvider, timeout(DEFAULT_TIMEOUT)).unrequestSocket(callback2);
 
         // Send IPv4 packet again and verify it's still sent a second time
-        mSocketClient.sendPacketRequestingMulticastResponse(ipv4Packet, socketKey2,
+        mSocketClient.sendPacketRequestingMulticastResponse(List.of(ipv4Packet), socketKey2,
                 false /* onlyUseIpv6OnIpv6OnlyNetworks */);
         HandlerUtils.waitForIdle(mHandler, DEFAULT_TIMEOUT);
         verify(socket2, times(2)).send(ipv4Packet);
@@ -320,7 +323,7 @@
         verify(mProvider, timeout(DEFAULT_TIMEOUT)).unrequestSocket(callback);
 
         // Send IPv4 packet and verify no more sending.
-        mSocketClient.sendPacketRequestingMulticastResponse(ipv4Packet, mSocketKey,
+        mSocketClient.sendPacketRequestingMulticastResponse(List.of(ipv4Packet), mSocketKey,
                 false /* onlyUseIpv6OnIpv6OnlyNetworks */);
         HandlerUtils.waitForIdle(mHandler, DEFAULT_TIMEOUT);
         verify(mSocket, times(1)).send(ipv4Packet);
@@ -407,4 +410,31 @@
         verify(creationCallback3).onSocketDestroyed(mSocketKey);
         verify(creationCallback3, never()).onSocketDestroyed(socketKey2);
     }
+
+    @Test
+    public void testSendPacketWithMultipleDatagramPacket() throws IOException {
+        final SocketCallback callback = expectSocketCallback();
+        final List<DatagramPacket> packets = new ArrayList<>();
+        for (int i = 0; i < 10; i++) {
+            packets.add(new DatagramPacket(new byte[10 + i] /* buff */, 0 /* offset */,
+                    10 + i /* length */, MdnsConstants.IPV4_SOCKET_ADDR));
+        }
+        doReturn(true).when(mSocket).hasJoinedIpv4();
+        doReturn(true).when(mSocket).hasJoinedIpv6();
+        doReturn(createEmptyNetworkInterface()).when(mSocket).getInterface();
+
+        // Notify socket created
+        callback.onSocketCreated(mSocketKey, mSocket, List.of());
+        verify(mSocketCreationCallback).onSocketCreated(mSocketKey);
+
+        // Send packets to IPv4 with mSocketKey then verify sending has been called and the
+        // sequence is correct.
+        mSocketClient.sendPacketRequestingMulticastResponse(packets, mSocketKey,
+                false /* onlyUseIpv6OnIpv6OnlyNetworks */);
+        HandlerUtils.waitForIdle(mHandler, DEFAULT_TIMEOUT);
+        InOrder inOrder = inOrder(mSocket);
+        for (int i = 0; i < 10; i++) {
+            inOrder.verify(mSocket).send(packets.get(i));
+        }
+    }
 }
diff --git a/tests/unit/java/com/android/server/connectivity/mdns/MdnsRecordRepositoryTest.kt b/tests/unit/java/com/android/server/connectivity/mdns/MdnsRecordRepositoryTest.kt
index fd8d98b..271cc65 100644
--- a/tests/unit/java/com/android/server/connectivity/mdns/MdnsRecordRepositoryTest.kt
+++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsRecordRepositoryTest.kt
@@ -24,6 +24,7 @@
 import com.android.server.connectivity.mdns.MdnsAnnouncer.AnnouncementInfo
 import com.android.server.connectivity.mdns.MdnsInterfaceAdvertiser.CONFLICT_HOST
 import com.android.server.connectivity.mdns.MdnsInterfaceAdvertiser.CONFLICT_SERVICE
+import com.android.server.connectivity.mdns.MdnsProber.ProbingInfo
 import com.android.server.connectivity.mdns.MdnsRecord.TYPE_A
 import com.android.server.connectivity.mdns.MdnsRecord.TYPE_AAAA
 import com.android.server.connectivity.mdns.MdnsRecord.TYPE_PTR
@@ -38,6 +39,7 @@
 import java.net.InetSocketAddress
 import java.net.NetworkInterface
 import java.util.Collections
+import java.time.Duration
 import kotlin.test.assertContentEquals
 import kotlin.test.assertEquals
 import kotlin.test.assertFailsWith
@@ -50,6 +52,10 @@
 import org.junit.Before
 import org.junit.Test
 import org.junit.runner.RunWith
+import org.mockito.ArgumentCaptor
+import org.mockito.ArgumentMatchers.eq
+import org.mockito.Mockito.mock
+import org.mockito.Mockito.verify
 
 private const val TEST_SERVICE_ID_1 = 42
 private const val TEST_SERVICE_ID_2 = 43
@@ -111,6 +117,14 @@
     port = TEST_PORT
 }
 
+private val TEST_SERVICE_CUSTOM_HOST_NO_ADDRESSES = NsdServiceInfo().apply {
+    hostname = "TestHost"
+    hostAddresses = listOf()
+    serviceType = "_testservice._tcp"
+    serviceName = "TestService"
+    port = TEST_PORT
+}
+
 @RunWith(DevSdkIgnoreRunner::class)
 @DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.S_V2)
 class MdnsRecordRepositoryTest {
@@ -145,7 +159,8 @@
     fun testAddServiceAndProbe() {
         val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, makeFlags())
         assertEquals(0, repository.servicesCount)
-        assertEquals(-1, repository.addService(TEST_SERVICE_ID_1, TEST_SERVICE_1))
+        assertEquals(-1,
+                repository.addService(TEST_SERVICE_ID_1, TEST_SERVICE_1, Duration.ofSeconds(50)))
         assertEquals(1, repository.servicesCount)
 
         val probingInfo = repository.setServiceProbing(TEST_SERVICE_ID_1)
@@ -168,7 +183,7 @@
         assertEquals(MdnsServiceRecord(expectedName,
                 0L /* receiptTimeMillis */,
                 false /* cacheFlush */,
-                SHORT_TTL /* ttlMillis */,
+                50_000L /* ttlMillis */,
                 0 /* servicePriority */, 0 /* serviceWeight */,
                 TEST_PORT, TEST_HOSTNAME), packet.authorityRecords[0])
 
@@ -180,10 +195,10 @@
         val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, makeFlags())
         repository.initWithService(TEST_SERVICE_ID_1, TEST_SERVICE_1)
         assertFailsWith(NameConflictException::class) {
-            repository.addService(TEST_SERVICE_ID_2, TEST_SERVICE_1)
+            repository.addService(TEST_SERVICE_ID_2, TEST_SERVICE_1, null /* ttl */)
         }
         assertFailsWith(NameConflictException::class) {
-            repository.addService(TEST_SERVICE_ID_3, TEST_SERVICE_3)
+            repository.addService(TEST_SERVICE_ID_3, TEST_SERVICE_3, null /* ttl */)
         }
     }
 
@@ -224,9 +239,9 @@
     @Test
     fun testInvalidReuseOfServiceId() {
         val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, makeFlags())
-        repository.addService(TEST_SERVICE_ID_1, TEST_SERVICE_1)
+        repository.addService(TEST_SERVICE_ID_1, TEST_SERVICE_1, null /* ttl */)
         assertFailsWith(IllegalArgumentException::class) {
-            repository.addService(TEST_SERVICE_ID_1, TEST_SERVICE_2)
+            repository.addService(TEST_SERVICE_ID_1, TEST_SERVICE_2, null /* ttl */)
         }
     }
 
@@ -235,7 +250,7 @@
         val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, makeFlags())
         assertFalse(repository.hasActiveService(TEST_SERVICE_ID_1))
 
-        repository.addService(TEST_SERVICE_ID_1, TEST_SERVICE_1)
+        repository.addService(TEST_SERVICE_ID_1, TEST_SERVICE_1, null /* ttl */)
         assertTrue(repository.hasActiveService(TEST_SERVICE_ID_1))
 
         val probingInfo = repository.setServiceProbing(TEST_SERVICE_ID_1)
@@ -327,7 +342,7 @@
         repository.exitService(TEST_SERVICE_ID_1)
 
         assertEquals(TEST_SERVICE_ID_1,
-                repository.addService(TEST_SERVICE_ID_2, TEST_SERVICE_1))
+                repository.addService(TEST_SERVICE_ID_2, TEST_SERVICE_1, null /* ttl */))
         assertEquals(1, repository.servicesCount)
 
         repository.removeService(TEST_SERVICE_ID_2)
@@ -824,7 +839,7 @@
         repository.initWithService(
                 TEST_CUSTOM_HOST_ID_1, TEST_CUSTOM_HOST_1, subtypes = setOf(),
                 listOf(LinkAddress(parseNumericAddress("192.0.2.111"), 24)))
-        repository.addService(TEST_CUSTOM_HOST_ID_2, TEST_CUSTOM_HOST_2)
+        repository.addService(TEST_CUSTOM_HOST_ID_2, TEST_CUSTOM_HOST_2, null /* ttl */)
         val src = InetSocketAddress(parseNumericAddress("fe80::123"), 5353)
 
         val query = makeQuery(TYPE_AAAA to TEST_CUSTOM_HOST_1_NAME)
@@ -890,7 +905,8 @@
         repository.initWithService(
                 TEST_CUSTOM_HOST_ID_1, TEST_CUSTOM_HOST_1, subtypes = setOf(),
                 listOf(LinkAddress(parseNumericAddress("192.0.2.111"), 24)))
-        repository.addService(TEST_SERVICE_CUSTOM_HOST_ID_1, TEST_SERVICE_CUSTOM_HOST_1)
+        repository.addService(
+                TEST_SERVICE_CUSTOM_HOST_ID_1, TEST_SERVICE_CUSTOM_HOST_1, null /* ttl */)
         repository.removeService(TEST_CUSTOM_HOST_ID_1)
         repository.removeService(TEST_SERVICE_CUSTOM_HOST_ID_1)
 
@@ -989,8 +1005,8 @@
     @Test
     fun testGetConflictingServices() {
         val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, makeFlags())
-        repository.addService(TEST_SERVICE_ID_1, TEST_SERVICE_1)
-        repository.addService(TEST_SERVICE_ID_2, TEST_SERVICE_2)
+        repository.addService(TEST_SERVICE_ID_1, TEST_SERVICE_1, null /* ttl */)
+        repository.addService(TEST_SERVICE_ID_2, TEST_SERVICE_2, null /* ttl */)
 
         val packet = MdnsPacket(
                 0 /* flags */,
@@ -1020,8 +1036,8 @@
     @Test
     fun testGetConflictingServicesCaseInsensitive() {
         val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, makeFlags())
-        repository.addService(TEST_SERVICE_ID_1, TEST_SERVICE_1)
-        repository.addService(TEST_SERVICE_ID_2, TEST_SERVICE_2)
+        repository.addService(TEST_SERVICE_ID_1, TEST_SERVICE_1, null /* ttl */)
+        repository.addService(TEST_SERVICE_ID_2, TEST_SERVICE_2, null /* ttl */)
 
         val packet = MdnsPacket(
             0 /* flags */,
@@ -1050,8 +1066,8 @@
     @Test
     fun testGetConflictingServices_customHosts_differentAddresses() {
         val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, makeFlags())
-        repository.addService(TEST_CUSTOM_HOST_ID_1, TEST_CUSTOM_HOST_1)
-        repository.addService(TEST_CUSTOM_HOST_ID_2, TEST_CUSTOM_HOST_2)
+        repository.addService(TEST_CUSTOM_HOST_ID_1, TEST_CUSTOM_HOST_1, null /* ttl */)
+        repository.addService(TEST_CUSTOM_HOST_ID_2, TEST_CUSTOM_HOST_2, null /* ttl */)
 
         val packet = MdnsPacket(
                 0, /* flags */
@@ -1074,8 +1090,8 @@
     @Test
     fun testGetConflictingServices_customHosts_moreAddressesThanUs_conflict() {
         val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, makeFlags())
-        repository.addService(TEST_CUSTOM_HOST_ID_1, TEST_CUSTOM_HOST_1)
-        repository.addService(TEST_CUSTOM_HOST_ID_2, TEST_CUSTOM_HOST_2)
+        repository.addService(TEST_CUSTOM_HOST_ID_1, TEST_CUSTOM_HOST_1, null /* ttl */)
+        repository.addService(TEST_CUSTOM_HOST_ID_2, TEST_CUSTOM_HOST_2, null /* ttl */)
 
         val packet = MdnsPacket(
                 0, /* flags */
@@ -1101,8 +1117,8 @@
     @Test
     fun testGetConflictingServices_customHostsReplyHasFewerAddressesThanUs_noConflict() {
         val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, makeFlags())
-        repository.addService(TEST_CUSTOM_HOST_ID_1, TEST_CUSTOM_HOST_1)
-        repository.addService(TEST_CUSTOM_HOST_ID_2, TEST_CUSTOM_HOST_2)
+        repository.addService(TEST_CUSTOM_HOST_ID_1, TEST_CUSTOM_HOST_1, null /* ttl */)
+        repository.addService(TEST_CUSTOM_HOST_ID_2, TEST_CUSTOM_HOST_2, null /* ttl */)
 
         val packet = MdnsPacket(
                 0, /* flags */
@@ -1122,8 +1138,8 @@
     @Test
     fun testGetConflictingServices_customHostsReplyHasIdenticalHosts_noConflict() {
         val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, makeFlags())
-        repository.addService(TEST_CUSTOM_HOST_ID_1, TEST_CUSTOM_HOST_1)
-        repository.addService(TEST_CUSTOM_HOST_ID_2, TEST_CUSTOM_HOST_2)
+        repository.addService(TEST_CUSTOM_HOST_ID_1, TEST_CUSTOM_HOST_1, null /* ttl */)
+        repository.addService(TEST_CUSTOM_HOST_ID_2, TEST_CUSTOM_HOST_2, null /* ttl */)
 
         val packet = MdnsPacket(
                 0, /* flags */
@@ -1147,8 +1163,8 @@
     @Test
     fun testGetConflictingServices_customHostsCaseInsensitiveReplyHasIdenticalHosts_noConflict() {
         val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, makeFlags())
-        repository.addService(TEST_CUSTOM_HOST_ID_1, TEST_CUSTOM_HOST_1)
-        repository.addService(TEST_CUSTOM_HOST_ID_2, TEST_CUSTOM_HOST_2)
+        repository.addService(TEST_CUSTOM_HOST_ID_1, TEST_CUSTOM_HOST_1, null /* ttl */)
+        repository.addService(TEST_CUSTOM_HOST_ID_2, TEST_CUSTOM_HOST_2, null /* ttl */)
 
         val packet = MdnsPacket(
                 0, /* flags */
@@ -1171,8 +1187,8 @@
     @Test
     fun testGetConflictingServices_IdenticalService() {
         val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, makeFlags())
-        repository.addService(TEST_SERVICE_ID_1, TEST_SERVICE_1)
-        repository.addService(TEST_SERVICE_ID_2, TEST_SERVICE_2)
+        repository.addService(TEST_SERVICE_ID_1, TEST_SERVICE_1, null /* ttl */)
+        repository.addService(TEST_SERVICE_ID_2, TEST_SERVICE_2, null /* ttl */)
 
         val otherTtlMillis = 1234L
         val packet = MdnsPacket(
@@ -1200,8 +1216,8 @@
     @Test
     fun testGetConflictingServicesCaseInsensitive_IdenticalService() {
         val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, makeFlags())
-        repository.addService(TEST_SERVICE_ID_1, TEST_SERVICE_1)
-        repository.addService(TEST_SERVICE_ID_2, TEST_SERVICE_2)
+        repository.addService(TEST_SERVICE_ID_1, TEST_SERVICE_1, null /* ttl */)
+        repository.addService(TEST_SERVICE_ID_2, TEST_SERVICE_2, null /* ttl */)
 
         val otherTtlMillis = 1234L
         val packet = MdnsPacket(
@@ -1256,7 +1272,8 @@
             makeFlags(includeInetAddressesInProbing = true))
         repository.updateAddresses(TEST_ADDRESSES)
         assertEquals(0, repository.servicesCount)
-        assertEquals(-1, repository.addService(TEST_SERVICE_ID_1, TEST_SERVICE_1))
+        assertEquals(-1,
+                repository.addService(TEST_SERVICE_ID_1, TEST_SERVICE_1, null /* ttl */))
         assertEquals(1, repository.servicesCount)
 
         val probingInfo = repository.setServiceProbing(TEST_SERVICE_ID_1)
@@ -1672,6 +1689,127 @@
         assertEquals(0, reply.additionalAnswers.size)
         assertEquals(knownAnswers, reply.knownAnswers)
     }
+
+    @Test
+    fun testRestartProbingForHostname() {
+        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, makeFlags())
+        repository.initWithService(TEST_CUSTOM_HOST_ID_1, TEST_CUSTOM_HOST_1,
+                setOf(TEST_SUBTYPE, TEST_SUBTYPE2))
+        repository.addService(TEST_SERVICE_CUSTOM_HOST_ID_1,
+                TEST_SERVICE_CUSTOM_HOST_NO_ADDRESSES, null)
+        repository.setServiceProbing(TEST_SERVICE_CUSTOM_HOST_ID_1)
+        repository.removeService(TEST_CUSTOM_HOST_ID_1)
+
+        val probingInfos = repository.restartProbingForHostname("TestHost")
+
+        assertEquals(1, probingInfos.size)
+        val probingInfo = probingInfos.get(0)
+        assertEquals(TEST_SERVICE_CUSTOM_HOST_ID_1, probingInfo.serviceId)
+        val packet = probingInfo.getPacket(0)
+        assertEquals(0, packet.transactionId)
+        assertEquals(MdnsConstants.FLAGS_QUERY, packet.flags)
+        assertEquals(0, packet.answers.size)
+        assertEquals(0, packet.additionalRecords.size)
+        assertEquals(1, packet.questions.size)
+        val serviceName = arrayOf("TestService", "_testservice", "_tcp", "local")
+        assertEquals(MdnsAnyRecord(serviceName, false /* unicast */), packet.questions[0])
+        assertThat(packet.authorityRecords).containsExactly(
+                MdnsServiceRecord(
+                        serviceName,
+                        0L /* receiptTimeMillis */,
+                        false /* cacheFlush */,
+                        SHORT_TTL /* ttlMillis */,
+                        0 /* servicePriority */,
+                        0 /* serviceWeight */,
+                        TEST_PORT,
+                        TEST_CUSTOM_HOST_1_NAME))
+    }
+
+    @Test
+    fun testRestartAnnouncingForHostname() {
+        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, makeFlags())
+        repository.initWithService(TEST_CUSTOM_HOST_ID_1, TEST_CUSTOM_HOST_1,
+                setOf(TEST_SUBTYPE, TEST_SUBTYPE2))
+        repository.addServiceAndFinishProbing(TEST_SERVICE_CUSTOM_HOST_ID_1,
+                TEST_SERVICE_CUSTOM_HOST_NO_ADDRESSES)
+        repository.removeService(TEST_CUSTOM_HOST_ID_1)
+
+        val announcementInfos = repository.restartAnnouncingForHostname("TestHost")
+
+        assertEquals(1, announcementInfos.size)
+        val announcementInfo = announcementInfos.get(0)
+        assertEquals(TEST_SERVICE_CUSTOM_HOST_ID_1, announcementInfo.serviceId)
+        val packet = announcementInfo.getPacket(0)
+        assertEquals(0, packet.transactionId)
+        assertEquals(0x8400 /* response, authoritative */, packet.flags)
+        assertEquals(0, packet.questions.size)
+        assertEquals(0, packet.authorityRecords.size)
+        val serviceName = arrayOf("TestService", "_testservice", "_tcp", "local")
+        val serviceType = arrayOf("_testservice", "_tcp", "local")
+        val v4AddrRev = getReverseDnsAddress(TEST_ADDRESSES[0].address)
+        val v6Addr1Rev = getReverseDnsAddress(TEST_ADDRESSES[1].address)
+        val v6Addr2Rev = getReverseDnsAddress(TEST_ADDRESSES[2].address)
+        assertThat(packet.answers).containsExactly(
+                MdnsPointerRecord(
+                        serviceType,
+                        0L /* receiptTimeMillis */,
+                        // Not a unique name owned by the announcer, so cacheFlush=false
+                        false /* cacheFlush */,
+                        4500000L /* ttlMillis */,
+                        serviceName),
+                MdnsServiceRecord(
+                        serviceName,
+                        0L /* receiptTimeMillis */,
+                        true /* cacheFlush */,
+                        120000L /* ttlMillis */,
+                        0 /* servicePriority */,
+                        0 /* serviceWeight */,
+                        TEST_PORT /* servicePort */,
+                        TEST_CUSTOM_HOST_1_NAME),
+                MdnsTextRecord(
+                        serviceName,
+                        0L /* receiptTimeMillis */,
+                        true /* cacheFlush */,
+                        4500000L /* ttlMillis */,
+                        emptyList() /* entries */),
+                MdnsPointerRecord(
+                        arrayOf("_services", "_dns-sd", "_udp", "local"),
+                        0L /* receiptTimeMillis */,
+                        false /* cacheFlush */,
+                        4500000L /* ttlMillis */,
+                        serviceType))
+        assertThat(packet.additionalRecords).containsExactly(
+                MdnsNsecRecord(v4AddrRev,
+                        0L /* receiptTimeMillis */,
+                        true /* cacheFlush */,
+                        120000L /* ttlMillis */,
+                        v4AddrRev,
+                        intArrayOf(TYPE_PTR)),
+                MdnsNsecRecord(TEST_HOSTNAME,
+                        0L /* receiptTimeMillis */,
+                        true /* cacheFlush */,
+                        120000L /* ttlMillis */,
+                        TEST_HOSTNAME,
+                        intArrayOf(TYPE_A, TYPE_AAAA)),
+                MdnsNsecRecord(v6Addr1Rev,
+                        0L /* receiptTimeMillis */,
+                        true /* cacheFlush */,
+                        120000L /* ttlMillis */,
+                        v6Addr1Rev,
+                        intArrayOf(TYPE_PTR)),
+                MdnsNsecRecord(v6Addr2Rev,
+                        0L /* receiptTimeMillis */,
+                        true /* cacheFlush */,
+                        120000L /* ttlMillis */,
+                        v6Addr2Rev,
+                        intArrayOf(TYPE_PTR)),
+                MdnsNsecRecord(serviceName,
+                        0L /* receiptTimeMillis */,
+                        true /* cacheFlush */,
+                        4500000L /* ttlMillis */,
+                        serviceName,
+                        intArrayOf(TYPE_TXT, TYPE_SRV)))
+    }
 }
 
 private fun MdnsRecordRepository.initWithService(
@@ -1689,7 +1827,7 @@
     serviceId: Int,
     serviceInfo: NsdServiceInfo
 ): AnnouncementInfo {
-    addService(serviceId, serviceInfo)
+    addService(serviceId, serviceInfo, null /* ttl */)
     val probingInfo = setServiceProbing(serviceId)
     assertNotNull(probingInfo)
     return onProbingSucceeded(probingInfo)
diff --git a/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceInfoTest.java b/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceInfoTest.java
index e7d7a98..4ce8ba6 100644
--- a/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceInfoTest.java
+++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceInfoTest.java
@@ -35,6 +35,7 @@
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
+import java.time.Instant;
 import java.util.List;
 import java.util.Map;
 
@@ -53,7 +54,8 @@
                         "192.168.1.1",
                         "2001::1",
                         List.of("vn=Google Inc.", "mn=Google Nest Hub Max"),
-                        /* textEntries= */ null);
+                        /* textEntries= */ null,
+                        INTERFACE_INDEX_UNSPECIFIED);
 
         assertTrue(info.getAttributeByKey("vn").equals("Google Inc."));
         assertTrue(info.getAttributeByKey("mn").equals("Google Nest Hub Max"));
@@ -72,7 +74,8 @@
                         "2001::1",
                         /* textStrings= */ null,
                         List.of(MdnsServiceInfo.TextEntry.fromString("vn=Google Inc."),
-                                MdnsServiceInfo.TextEntry.fromString("mn=Google Nest Hub Max")));
+                                MdnsServiceInfo.TextEntry.fromString("mn=Google Nest Hub Max")),
+                        INTERFACE_INDEX_UNSPECIFIED);
 
         assertTrue(info.getAttributeByKey("vn").equals("Google Inc."));
         assertTrue(info.getAttributeByKey("mn").equals("Google Nest Hub Max"));
@@ -92,7 +95,8 @@
                         List.of("vn=Alphabet Inc.", "mn=Google Nest Hub Max", "id=12345"),
                         List.of(
                                 MdnsServiceInfo.TextEntry.fromString("vn=Google Inc."),
-                                MdnsServiceInfo.TextEntry.fromString("mn=Google Nest Hub Max")));
+                                MdnsServiceInfo.TextEntry.fromString("mn=Google Nest Hub Max")),
+                        INTERFACE_INDEX_UNSPECIFIED);
 
         assertEquals(Map.of("vn", "Google Inc.", "mn", "Google Nest Hub Max"),
                 info.getAttributes());
@@ -112,7 +116,8 @@
                         List.of("vn=Alphabet Inc.", "mn=Google Nest Hub Max", "id=12345"),
                         List.of(MdnsServiceInfo.TextEntry.fromString("vn=Google Inc."),
                                 MdnsServiceInfo.TextEntry.fromString("mn=Google Nest Hub Max"),
-                                MdnsServiceInfo.TextEntry.fromString("mn=Google WiFi Router")));
+                                MdnsServiceInfo.TextEntry.fromString("mn=Google WiFi Router")),
+                        INTERFACE_INDEX_UNSPECIFIED);
 
         assertEquals(Map.of("vn", "Google Inc.", "mn", "Google Nest Hub Max"),
                 info.getAttributes());
@@ -130,7 +135,8 @@
                         "192.168.1.1",
                         "2001::1",
                         List.of("KEY=Value"),
-                        /* textEntries= */ null);
+                        /* textEntries= */ null,
+                        INTERFACE_INDEX_UNSPECIFIED);
 
         assertEquals("Value", info.getAttributeByKey("key"));
         assertEquals("Value", info.getAttributeByKey("KEY"));
@@ -149,7 +155,9 @@
                         12345,
                         "192.168.1.1",
                         "2001::1",
-                        List.of());
+                        List.of(),
+                        /* textEntries= */ null,
+                        INTERFACE_INDEX_UNSPECIFIED);
 
         assertEquals(info.getInterfaceIndex(), INTERFACE_INDEX_UNSPECIFIED);
     }
@@ -202,7 +210,8 @@
                         List.of(),
                         /* textEntries= */ null,
                         /* interfaceIndex= */ 20,
-                        network);
+                        network,
+                        Instant.MAX /* expirationTime */);
 
         assertEquals(network, info2.getNetwork());
     }
@@ -225,7 +234,8 @@
                                 MdnsServiceInfo.TextEntry.fromString("mn=Google Nest Hub Max"),
                                 MdnsServiceInfo.TextEntry.fromString("test=")),
                         20 /* interfaceIndex */,
-                        new Network(123));
+                        new Network(123),
+                        Instant.MAX /* expirationTime */);
 
         beforeParcel.writeToParcel(parcel, 0);
         parcel.setDataPosition(0);
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 58124f3..44fa55c 100644
--- a/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceTypeClientTests.java
+++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceTypeClientTests.java
@@ -38,11 +38,13 @@
 import static org.mockito.ArgumentMatchers.argThat;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doCallRealMethod;
 import static org.mockito.Mockito.doReturn;
 import static org.mockito.Mockito.inOrder;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
 import static org.mockito.Mockito.when;
 
 import static java.nio.charset.StandardCharsets.UTF_8;
@@ -116,8 +118,6 @@
     @Mock
     private MdnsServiceBrowserListener mockListenerTwo;
     @Mock
-    private MdnsPacketWriter mockPacketWriter;
-    @Mock
     private MdnsMultinetworkSocketClient mockSocketClient;
     @Mock
     private Network mockNetwork;
@@ -144,6 +144,7 @@
     private long latestDelayMs = 0;
     private Message delayMessage = null;
     private Handler realHandler = null;
+    private MdnsFeatureFlags featureFlags = MdnsFeatureFlags.newBuilder().build();
 
     @Before
     @SuppressWarnings("DoNotMock")
@@ -161,57 +162,59 @@
             expectedIPv6Packets[i] = new DatagramPacket(buf, 0 /* offset */, 5 /* length */,
                     MdnsConstants.getMdnsIPv6Address(), MdnsConstants.MDNS_PORT);
         }
-        when(mockPacketWriter.getPacket(IPV4_ADDRESS))
-                .thenReturn(expectedIPv4Packets[0])
-                .thenReturn(expectedIPv4Packets[1])
-                .thenReturn(expectedIPv4Packets[2])
-                .thenReturn(expectedIPv4Packets[3])
-                .thenReturn(expectedIPv4Packets[4])
-                .thenReturn(expectedIPv4Packets[5])
-                .thenReturn(expectedIPv4Packets[6])
-                .thenReturn(expectedIPv4Packets[7])
-                .thenReturn(expectedIPv4Packets[8])
-                .thenReturn(expectedIPv4Packets[9])
-                .thenReturn(expectedIPv4Packets[10])
-                .thenReturn(expectedIPv4Packets[11])
-                .thenReturn(expectedIPv4Packets[12])
-                .thenReturn(expectedIPv4Packets[13])
-                .thenReturn(expectedIPv4Packets[14])
-                .thenReturn(expectedIPv4Packets[15])
-                .thenReturn(expectedIPv4Packets[16])
-                .thenReturn(expectedIPv4Packets[17])
-                .thenReturn(expectedIPv4Packets[18])
-                .thenReturn(expectedIPv4Packets[19])
-                .thenReturn(expectedIPv4Packets[20])
-                .thenReturn(expectedIPv4Packets[21])
-                .thenReturn(expectedIPv4Packets[22])
-                .thenReturn(expectedIPv4Packets[23]);
+        when(mockDeps.getDatagramPacketsFromMdnsPacket(
+                any(), any(MdnsPacket.class), eq(IPV4_ADDRESS), anyBoolean()))
+                .thenReturn(List.of(expectedIPv4Packets[0]))
+                .thenReturn(List.of(expectedIPv4Packets[1]))
+                .thenReturn(List.of(expectedIPv4Packets[2]))
+                .thenReturn(List.of(expectedIPv4Packets[3]))
+                .thenReturn(List.of(expectedIPv4Packets[4]))
+                .thenReturn(List.of(expectedIPv4Packets[5]))
+                .thenReturn(List.of(expectedIPv4Packets[6]))
+                .thenReturn(List.of(expectedIPv4Packets[7]))
+                .thenReturn(List.of(expectedIPv4Packets[8]))
+                .thenReturn(List.of(expectedIPv4Packets[9]))
+                .thenReturn(List.of(expectedIPv4Packets[10]))
+                .thenReturn(List.of(expectedIPv4Packets[11]))
+                .thenReturn(List.of(expectedIPv4Packets[12]))
+                .thenReturn(List.of(expectedIPv4Packets[13]))
+                .thenReturn(List.of(expectedIPv4Packets[14]))
+                .thenReturn(List.of(expectedIPv4Packets[15]))
+                .thenReturn(List.of(expectedIPv4Packets[16]))
+                .thenReturn(List.of(expectedIPv4Packets[17]))
+                .thenReturn(List.of(expectedIPv4Packets[18]))
+                .thenReturn(List.of(expectedIPv4Packets[19]))
+                .thenReturn(List.of(expectedIPv4Packets[20]))
+                .thenReturn(List.of(expectedIPv4Packets[21]))
+                .thenReturn(List.of(expectedIPv4Packets[22]))
+                .thenReturn(List.of(expectedIPv4Packets[23]));
 
-        when(mockPacketWriter.getPacket(IPV6_ADDRESS))
-                .thenReturn(expectedIPv6Packets[0])
-                .thenReturn(expectedIPv6Packets[1])
-                .thenReturn(expectedIPv6Packets[2])
-                .thenReturn(expectedIPv6Packets[3])
-                .thenReturn(expectedIPv6Packets[4])
-                .thenReturn(expectedIPv6Packets[5])
-                .thenReturn(expectedIPv6Packets[6])
-                .thenReturn(expectedIPv6Packets[7])
-                .thenReturn(expectedIPv6Packets[8])
-                .thenReturn(expectedIPv6Packets[9])
-                .thenReturn(expectedIPv6Packets[10])
-                .thenReturn(expectedIPv6Packets[11])
-                .thenReturn(expectedIPv6Packets[12])
-                .thenReturn(expectedIPv6Packets[13])
-                .thenReturn(expectedIPv6Packets[14])
-                .thenReturn(expectedIPv6Packets[15])
-                .thenReturn(expectedIPv6Packets[16])
-                .thenReturn(expectedIPv6Packets[17])
-                .thenReturn(expectedIPv6Packets[18])
-                .thenReturn(expectedIPv6Packets[19])
-                .thenReturn(expectedIPv6Packets[20])
-                .thenReturn(expectedIPv6Packets[21])
-                .thenReturn(expectedIPv6Packets[22])
-                .thenReturn(expectedIPv6Packets[23]);
+        when(mockDeps.getDatagramPacketsFromMdnsPacket(
+                any(), any(MdnsPacket.class), eq(IPV6_ADDRESS), anyBoolean()))
+                .thenReturn(List.of(expectedIPv6Packets[0]))
+                .thenReturn(List.of(expectedIPv6Packets[1]))
+                .thenReturn(List.of(expectedIPv6Packets[2]))
+                .thenReturn(List.of(expectedIPv6Packets[3]))
+                .thenReturn(List.of(expectedIPv6Packets[4]))
+                .thenReturn(List.of(expectedIPv6Packets[5]))
+                .thenReturn(List.of(expectedIPv6Packets[6]))
+                .thenReturn(List.of(expectedIPv6Packets[7]))
+                .thenReturn(List.of(expectedIPv6Packets[8]))
+                .thenReturn(List.of(expectedIPv6Packets[9]))
+                .thenReturn(List.of(expectedIPv6Packets[10]))
+                .thenReturn(List.of(expectedIPv6Packets[11]))
+                .thenReturn(List.of(expectedIPv6Packets[12]))
+                .thenReturn(List.of(expectedIPv6Packets[13]))
+                .thenReturn(List.of(expectedIPv6Packets[14]))
+                .thenReturn(List.of(expectedIPv6Packets[15]))
+                .thenReturn(List.of(expectedIPv6Packets[16]))
+                .thenReturn(List.of(expectedIPv6Packets[17]))
+                .thenReturn(List.of(expectedIPv6Packets[18]))
+                .thenReturn(List.of(expectedIPv6Packets[19]))
+                .thenReturn(List.of(expectedIPv6Packets[20]))
+                .thenReturn(List.of(expectedIPv6Packets[21]))
+                .thenReturn(List.of(expectedIPv6Packets[22]))
+                .thenReturn(List.of(expectedIPv6Packets[23]));
 
         thread = new HandlerThread("MdnsServiceTypeClientTests");
         thread.start();
@@ -241,22 +244,13 @@
             return true;
         }).when(mockDeps).sendMessage(any(Handler.class), any(Message.class));
 
-        client = makeMdnsServiceTypeClient(mockPacketWriter);
+        client = makeMdnsServiceTypeClient();
     }
 
-    private MdnsServiceTypeClient makeMdnsServiceTypeClient(
-            @Nullable MdnsPacketWriter packetWriter) {
+    private MdnsServiceTypeClient makeMdnsServiceTypeClient() {
         return new MdnsServiceTypeClient(SERVICE_TYPE, mockSocketClient, currentThreadExecutor,
                 mockDecoderClock, socketKey, mockSharedLog, thread.getLooper(), mockDeps,
-                serviceCache) {
-            @Override
-            MdnsPacketWriter createMdnsPacketWriter() {
-                if (packetWriter == null) {
-                    return super.createMdnsPacketWriter();
-                }
-                return packetWriter;
-            }
-        };
+                serviceCache, featureFlags);
     }
 
     @After
@@ -696,27 +690,27 @@
 
     @Test
     public void testCombinedSubtypesQueriedWithMultipleListeners() throws Exception {
-        client = makeMdnsServiceTypeClient(/* packetWriter= */ null);
         final MdnsSearchOptions searchOptions1 = MdnsSearchOptions.newBuilder()
                 .addSubtype("subtype1").build();
         final MdnsSearchOptions searchOptions2 = MdnsSearchOptions.newBuilder()
                 .addSubtype("subtype2").build();
+        doCallRealMethod().when(mockDeps).getDatagramPacketsFromMdnsPacket(
+                any(), any(MdnsPacket.class), any(InetSocketAddress.class), anyBoolean());
         startSendAndReceive(mockListenerOne, searchOptions1);
-        currentThreadExecutor.getAndClearSubmittedRunnable().run();
+        currentThreadExecutor.getAndClearLastScheduledRunnable().run();
 
         InOrder inOrder = inOrder(mockListenerOne, mockSocketClient, mockDeps);
 
         // Verify the query asks for subtype1
-        final ArgumentCaptor<DatagramPacket> subtype1QueryCaptor =
-                ArgumentCaptor.forClass(DatagramPacket.class);
-        currentThreadExecutor.getAndClearLastScheduledRunnable().run();
+        final ArgumentCaptor<List<DatagramPacket>> subtype1QueryCaptor =
+                ArgumentCaptor.forClass(List.class);
         // Send twice for IPv4 and IPv6
         inOrder.verify(mockSocketClient, times(2)).sendPacketRequestingUnicastResponse(
                 subtype1QueryCaptor.capture(),
                 eq(socketKey), eq(false));
 
         final MdnsPacket subtype1Query = MdnsPacket.parse(
-                new MdnsPacketReader(subtype1QueryCaptor.getValue()));
+                new MdnsPacketReader(subtype1QueryCaptor.getValue().get(0)));
 
         assertEquals(2, subtype1Query.questions.size());
         assertTrue(hasQuestion(subtype1Query, MdnsRecord.TYPE_PTR, SERVICE_TYPE_LABELS));
@@ -728,8 +722,8 @@
         inOrder.verify(mockDeps).removeMessages(any(), eq(EVENT_START_QUERYTASK));
         currentThreadExecutor.getAndClearLastScheduledRunnable().run();
 
-        final ArgumentCaptor<DatagramPacket> combinedSubtypesQueryCaptor =
-                ArgumentCaptor.forClass(DatagramPacket.class);
+        final ArgumentCaptor<List<DatagramPacket>> combinedSubtypesQueryCaptor =
+                ArgumentCaptor.forClass(List.class);
         inOrder.verify(mockSocketClient, times(2)).sendPacketRequestingUnicastResponse(
                 combinedSubtypesQueryCaptor.capture(),
                 eq(socketKey), eq(false));
@@ -737,7 +731,7 @@
         inOrder.verify(mockDeps).sendMessageDelayed(any(), any(), anyLong());
 
         final MdnsPacket combinedSubtypesQuery = MdnsPacket.parse(
-                new MdnsPacketReader(combinedSubtypesQueryCaptor.getValue()));
+                new MdnsPacketReader(combinedSubtypesQueryCaptor.getValue().get(0)));
 
         assertEquals(3, combinedSubtypesQuery.questions.size());
         assertTrue(hasQuestion(combinedSubtypesQuery, MdnsRecord.TYPE_PTR, SERVICE_TYPE_LABELS));
@@ -753,15 +747,15 @@
         dispatchMessage();
         currentThreadExecutor.getAndClearLastScheduledRunnable().run();
 
-        final ArgumentCaptor<DatagramPacket> subtype2QueryCaptor =
-                ArgumentCaptor.forClass(DatagramPacket.class);
+        final ArgumentCaptor<List<DatagramPacket>> subtype2QueryCaptor =
+                ArgumentCaptor.forClass(List.class);
         // Send twice for IPv4 and IPv6
         inOrder.verify(mockSocketClient, times(2)).sendPacketRequestingMulticastResponse(
                 subtype2QueryCaptor.capture(),
                 eq(socketKey), eq(false));
 
         final MdnsPacket subtype2Query = MdnsPacket.parse(
-                new MdnsPacketReader(subtype2QueryCaptor.getValue()));
+                new MdnsPacketReader(subtype2QueryCaptor.getValue().get(0)));
 
         assertEquals(2, subtype2Query.questions.size());
         assertTrue(hasQuestion(subtype2Query, MdnsRecord.TYPE_PTR, SERVICE_TYPE_LABELS));
@@ -1021,7 +1015,6 @@
     public void processResponse_searchOptionsEnableServiceRemoval_shouldRemove()
             throws Exception {
         final String serviceInstanceName = "service-instance-1";
-        client = makeMdnsServiceTypeClient(mockPacketWriter);
         MdnsSearchOptions searchOptions = MdnsSearchOptions.newBuilder()
                 .setRemoveExpiredService(true)
                 .setNumOfQueriesBeforeBackoff(Integer.MAX_VALUE)
@@ -1059,7 +1052,6 @@
     public void processResponse_searchOptionsNotEnableServiceRemoval_shouldNotRemove()
             throws Exception {
         final String serviceInstanceName = "service-instance-1";
-        client = makeMdnsServiceTypeClient(mockPacketWriter);
         startSendAndReceive(mockListenerOne, MdnsSearchOptions.getDefaultOptions());
         Runnable firstMdnsTask = currentThreadExecutor.getAndClearSubmittedRunnable();
 
@@ -1085,7 +1077,6 @@
             throws Exception {
         //MdnsConfigsFlagsImpl.removeServiceAfterTtlExpires.override(true);
         final String serviceInstanceName = "service-instance-1";
-        client = makeMdnsServiceTypeClient(mockPacketWriter);
         startSendAndReceive(mockListenerOne, MdnsSearchOptions.getDefaultOptions());
         Runnable firstMdnsTask = currentThreadExecutor.getAndClearSubmittedRunnable();
 
@@ -1200,22 +1191,27 @@
 
     @Test
     public void testProcessResponse_Resolve() throws Exception {
-        client = makeMdnsServiceTypeClient(/* packetWriter= */ null);
-
         final String instanceName = "service-instance";
         final String[] hostname = new String[] { "testhost "};
         final String ipV4Address = "192.0.2.0";
         final String ipV6Address = "2001:db8::";
 
-        final MdnsSearchOptions resolveOptions = MdnsSearchOptions.newBuilder()
+        final MdnsSearchOptions resolveOptions1 = MdnsSearchOptions.newBuilder()
+                .setResolveInstanceName(instanceName).build();
+        final MdnsSearchOptions resolveOptions2 = MdnsSearchOptions.newBuilder()
                 .setResolveInstanceName(instanceName).build();
 
-        startSendAndReceive(mockListenerOne, resolveOptions);
+        doCallRealMethod().when(mockDeps).getDatagramPacketsFromMdnsPacket(
+                any(), any(MdnsPacket.class), any(InetSocketAddress.class), anyBoolean());
+
+        startSendAndReceive(mockListenerOne, resolveOptions1);
+        startSendAndReceive(mockListenerTwo, resolveOptions2);
+        // No need to verify order for both listeners; and order is not guaranteed between them
         InOrder inOrder = inOrder(mockListenerOne, mockSocketClient);
 
         // Verify a query for SRV/TXT was sent, but no PTR query
-        final ArgumentCaptor<DatagramPacket> srvTxtQueryCaptor =
-                ArgumentCaptor.forClass(DatagramPacket.class);
+        final ArgumentCaptor<List<DatagramPacket>> srvTxtQueryCaptor =
+                ArgumentCaptor.forClass(List.class);
         currentThreadExecutor.getAndClearLastScheduledRunnable().run();
         // Send twice for IPv4 and IPv6
         inOrder.verify(mockSocketClient, times(2)).sendPacketRequestingUnicastResponse(
@@ -1223,13 +1219,19 @@
                 eq(socketKey), eq(false));
         verify(mockDeps, times(1)).sendMessage(any(), any(Message.class));
         assertNotNull(delayMessage);
+        inOrder.verify(mockListenerOne).onDiscoveryQuerySent(any(), anyInt());
+        verify(mockListenerTwo).onDiscoveryQuerySent(any(), anyInt());
 
         final MdnsPacket srvTxtQueryPacket = MdnsPacket.parse(
-                new MdnsPacketReader(srvTxtQueryCaptor.getValue()));
+                new MdnsPacketReader(srvTxtQueryCaptor.getValue().get(0)));
 
         final String[] serviceName = getTestServiceName(instanceName);
+        assertEquals(1, srvTxtQueryPacket.questions.size());
         assertFalse(hasQuestion(srvTxtQueryPacket, MdnsRecord.TYPE_PTR));
         assertTrue(hasQuestion(srvTxtQueryPacket, MdnsRecord.TYPE_ANY, serviceName));
+        assertEquals(0, srvTxtQueryPacket.answers.size());
+        assertEquals(0, srvTxtQueryPacket.authorityRecords.size());
+        assertEquals(0, srvTxtQueryPacket.additionalRecords.size());
 
         // Process a response with SRV+TXT
         final MdnsPacket srvTxtResponse = new MdnsPacket(
@@ -1246,20 +1248,31 @@
                 Collections.emptyList() /* additionalRecords */);
 
         processResponse(srvTxtResponse, socketKey);
+        inOrder.verify(mockListenerOne).onServiceNameDiscovered(
+                matchServiceName(instanceName), eq(false) /* isServiceFromCache */);
+        verify(mockListenerTwo).onServiceNameDiscovered(
+                matchServiceName(instanceName), eq(false) /* isServiceFromCache */);
 
         // Expect a query for A/AAAA
         dispatchMessage();
-        final ArgumentCaptor<DatagramPacket> addressQueryCaptor =
-                ArgumentCaptor.forClass(DatagramPacket.class);
+        final ArgumentCaptor<List<DatagramPacket>> addressQueryCaptor =
+                ArgumentCaptor.forClass(List.class);
         currentThreadExecutor.getAndClearLastScheduledRunnable().run();
         inOrder.verify(mockSocketClient, times(2)).sendPacketRequestingMulticastResponse(
                 addressQueryCaptor.capture(),
                 eq(socketKey), eq(false));
+        inOrder.verify(mockListenerOne).onDiscoveryQuerySent(any(), anyInt());
+        // onDiscoveryQuerySent was called 2 times in total
+        verify(mockListenerTwo, times(2)).onDiscoveryQuerySent(any(), anyInt());
 
         final MdnsPacket addressQueryPacket = MdnsPacket.parse(
-                new MdnsPacketReader(addressQueryCaptor.getValue()));
+                new MdnsPacketReader(addressQueryCaptor.getValue().get(0)));
+        assertEquals(2, addressQueryPacket.questions.size());
         assertTrue(hasQuestion(addressQueryPacket, MdnsRecord.TYPE_A, hostname));
         assertTrue(hasQuestion(addressQueryPacket, MdnsRecord.TYPE_AAAA, hostname));
+        assertEquals(0, addressQueryPacket.answers.size());
+        assertEquals(0, addressQueryPacket.authorityRecords.size());
+        assertEquals(0, addressQueryPacket.additionalRecords.size());
 
         // Process a response with address records
         final MdnsPacket addressResponse = new MdnsPacket(
@@ -1276,10 +1289,12 @@
                 Collections.emptyList() /* additionalRecords */);
 
         inOrder.verify(mockListenerOne, never()).onServiceNameDiscovered(any(), anyBoolean());
+        verifyNoMoreInteractions(mockListenerTwo);
         processResponse(addressResponse, socketKey);
 
         inOrder.verify(mockListenerOne).onServiceFound(
                 serviceInfoCaptor.capture(), eq(false) /* isServiceFromCache */);
+        verify(mockListenerTwo).onServiceFound(any(), anyBoolean());
         verifyServiceInfo(serviceInfoCaptor.getValue(),
                 instanceName,
                 SERVICE_TYPE_LABELS,
@@ -1293,8 +1308,6 @@
 
     @Test
     public void testRenewTxtSrvInResolve() throws Exception {
-        client = makeMdnsServiceTypeClient(/* packetWriter= */ null);
-
         final String instanceName = "service-instance";
         final String[] hostname = new String[] { "testhost "};
         final String ipV4Address = "192.0.2.0";
@@ -1303,12 +1316,15 @@
         final MdnsSearchOptions resolveOptions = MdnsSearchOptions.newBuilder()
                 .setResolveInstanceName(instanceName).build();
 
+        doCallRealMethod().when(mockDeps).getDatagramPacketsFromMdnsPacket(
+                any(), any(MdnsPacket.class), any(InetSocketAddress.class), anyBoolean());
+
         startSendAndReceive(mockListenerOne, resolveOptions);
         InOrder inOrder = inOrder(mockListenerOne, mockSocketClient);
 
         // Get the query for SRV/TXT
-        final ArgumentCaptor<DatagramPacket> srvTxtQueryCaptor =
-                ArgumentCaptor.forClass(DatagramPacket.class);
+        final ArgumentCaptor<List<DatagramPacket>> srvTxtQueryCaptor =
+                ArgumentCaptor.forClass(List.class);
         currentThreadExecutor.getAndClearLastScheduledRunnable().run();
         // Send twice for IPv4 and IPv6
         inOrder.verify(mockSocketClient, times(2)).sendPacketRequestingUnicastResponse(
@@ -1318,7 +1334,7 @@
         assertNotNull(delayMessage);
 
         final MdnsPacket srvTxtQueryPacket = MdnsPacket.parse(
-                new MdnsPacketReader(srvTxtQueryCaptor.getValue()));
+                new MdnsPacketReader(srvTxtQueryCaptor.getValue().get(0)));
 
         final String[] serviceName = getTestServiceName(instanceName);
         assertTrue(hasQuestion(srvTxtQueryPacket, MdnsRecord.TYPE_ANY, serviceName));
@@ -1362,8 +1378,8 @@
         currentThreadExecutor.getAndClearLastScheduledRunnable().run();
 
         // Expect a renewal query
-        final ArgumentCaptor<DatagramPacket> renewalQueryCaptor =
-                ArgumentCaptor.forClass(DatagramPacket.class);
+        final ArgumentCaptor<List<DatagramPacket>> renewalQueryCaptor =
+                ArgumentCaptor.forClass(List.class);
         // Second and later sends are sent as "expect multicast response" queries
         inOrder.verify(mockSocketClient, times(2)).sendPacketRequestingMulticastResponse(
                 renewalQueryCaptor.capture(),
@@ -1372,7 +1388,7 @@
         assertNotNull(delayMessage);
         inOrder.verify(mockListenerOne).onDiscoveryQuerySent(any(), anyInt());
         final MdnsPacket renewalPacket = MdnsPacket.parse(
-                new MdnsPacketReader(renewalQueryCaptor.getValue()));
+                new MdnsPacketReader(renewalQueryCaptor.getValue().get(0)));
         assertTrue(hasQuestion(renewalPacket, MdnsRecord.TYPE_ANY, serviceName));
         inOrder.verifyNoMoreInteractions();
 
@@ -1407,8 +1423,6 @@
 
     @Test
     public void testProcessResponse_ResolveExcludesOtherServices() {
-        client = makeMdnsServiceTypeClient(/* packetWriter= */ null);
-
         final String requestedInstance = "instance1";
         final String otherInstance = "instance2";
         final String ipV4Address = "192.0.2.0";
@@ -1475,8 +1489,6 @@
 
     @Test
     public void testProcessResponse_SubtypeDiscoveryLimitedToSubtype() {
-        client = makeMdnsServiceTypeClient(/* packetWriter= */ null);
-
         final String matchingInstance = "instance1";
         final String subtype = "_subtype";
         final String otherInstance = "instance2";
@@ -1563,8 +1575,6 @@
 
     @Test
     public void testProcessResponse_SubtypeChange() {
-        client = makeMdnsServiceTypeClient(/* packetWriter= */ null);
-
         final String matchingInstance = "instance1";
         final String subtype = "_subtype";
         final String ipV4Address = "192.0.2.0";
@@ -1646,8 +1656,6 @@
 
     @Test
     public void testNotifySocketDestroyed() throws Exception {
-        client = makeMdnsServiceTypeClient(/* packetWriter= */ null);
-
         final String requestedInstance = "instance1";
         final String otherInstance = "instance2";
         final String ipV4Address = "192.0.2.0";
@@ -1922,6 +1930,138 @@
                 16 /* scheduledCount */);
     }
 
+    @Test
+    public void testSendQueryWithKnownAnswers() throws Exception {
+        client = new MdnsServiceTypeClient(SERVICE_TYPE, mockSocketClient, currentThreadExecutor,
+                mockDecoderClock, socketKey, mockSharedLog, thread.getLooper(), mockDeps,
+                serviceCache,
+                MdnsFeatureFlags.newBuilder().setIsQueryWithKnownAnswerEnabled(true).build());
+
+        doCallRealMethod().when(mockDeps).getDatagramPacketsFromMdnsPacket(
+                any(), any(MdnsPacket.class), any(InetSocketAddress.class), anyBoolean());
+
+        startSendAndReceive(mockListenerOne, MdnsSearchOptions.getDefaultOptions());
+        InOrder inOrder = inOrder(mockListenerOne, mockSocketClient);
+
+        final ArgumentCaptor<List<DatagramPacket>> queryCaptor =
+                ArgumentCaptor.forClass(List.class);
+        currentThreadExecutor.getAndClearLastScheduledRunnable().run();
+        // Send twice for IPv4 and IPv6
+        inOrder.verify(mockSocketClient, times(2)).sendPacketRequestingUnicastResponse(
+                queryCaptor.capture(), eq(socketKey), eq(false));
+        verify(mockDeps, times(1)).sendMessage(any(), any(Message.class));
+        assertNotNull(delayMessage);
+
+        final MdnsPacket queryPacket = MdnsPacket.parse(
+                new MdnsPacketReader(queryCaptor.getValue().get(0)));
+        assertTrue(hasQuestion(queryPacket, MdnsRecord.TYPE_PTR));
+
+        // Process a response
+        final String serviceName = "service-instance";
+        final String ipV4Address = "192.0.2.0";
+        final String[] subtypeLabels = Stream.concat(Stream.of("_subtype", "_sub"),
+                        Arrays.stream(SERVICE_TYPE_LABELS)).toArray(String[]::new);
+        final MdnsPacket packetWithoutSubtype = createResponse(
+                serviceName, ipV4Address, 5353, SERVICE_TYPE_LABELS,
+                Collections.emptyMap() /* textAttributes */, TEST_TTL);
+        final MdnsPointerRecord originalPtr = (MdnsPointerRecord) CollectionUtils.findFirst(
+                packetWithoutSubtype.answers, r -> r instanceof MdnsPointerRecord);
+
+        // Add a subtype PTR record
+        final ArrayList<MdnsRecord> newAnswers = new ArrayList<>(packetWithoutSubtype.answers);
+        newAnswers.add(new MdnsPointerRecord(subtypeLabels, originalPtr.getReceiptTime(),
+                originalPtr.getCacheFlush(), originalPtr.getTtl(), originalPtr.getPointer()));
+        final MdnsPacket packetWithSubtype = new MdnsPacket(
+                packetWithoutSubtype.flags,
+                packetWithoutSubtype.questions,
+                newAnswers,
+                packetWithoutSubtype.authorityRecords,
+                packetWithoutSubtype.additionalRecords);
+        processResponse(packetWithSubtype, socketKey);
+
+        // Expect a query with known answers
+        dispatchMessage();
+        final ArgumentCaptor<List<DatagramPacket>> knownAnswersQueryCaptor =
+                ArgumentCaptor.forClass(List.class);
+        currentThreadExecutor.getAndClearLastScheduledRunnable().run();
+        inOrder.verify(mockSocketClient, times(2)).sendPacketRequestingMulticastResponse(
+                knownAnswersQueryCaptor.capture(), eq(socketKey), eq(false));
+
+        final MdnsPacket knownAnswersQueryPacket = MdnsPacket.parse(
+                new MdnsPacketReader(knownAnswersQueryCaptor.getValue().get(0)));
+        assertTrue(hasQuestion(knownAnswersQueryPacket, MdnsRecord.TYPE_PTR, SERVICE_TYPE_LABELS));
+        assertTrue(hasAnswer(knownAnswersQueryPacket, MdnsRecord.TYPE_PTR, SERVICE_TYPE_LABELS));
+        assertFalse(hasAnswer(knownAnswersQueryPacket, MdnsRecord.TYPE_PTR, subtypeLabels));
+    }
+
+    @Test
+    public void testSendQueryWithSubTypeWithKnownAnswers() throws Exception {
+        client = new MdnsServiceTypeClient(SERVICE_TYPE, mockSocketClient, currentThreadExecutor,
+                mockDecoderClock, socketKey, mockSharedLog, thread.getLooper(), mockDeps,
+                serviceCache,
+                MdnsFeatureFlags.newBuilder().setIsQueryWithKnownAnswerEnabled(true).build());
+
+        doCallRealMethod().when(mockDeps).getDatagramPacketsFromMdnsPacket(
+                any(), any(MdnsPacket.class), any(InetSocketAddress.class), anyBoolean());
+
+        final MdnsSearchOptions options = MdnsSearchOptions.newBuilder()
+                .addSubtype("subtype").build();
+        startSendAndReceive(mockListenerOne, options);
+        InOrder inOrder = inOrder(mockListenerOne, mockSocketClient);
+
+        final ArgumentCaptor<List<DatagramPacket>> queryCaptor =
+                ArgumentCaptor.forClass(List.class);
+        currentThreadExecutor.getAndClearLastScheduledRunnable().run();
+        // Send twice for IPv4 and IPv6
+        inOrder.verify(mockSocketClient, times(2)).sendPacketRequestingUnicastResponse(
+                queryCaptor.capture(), eq(socketKey), eq(false));
+        verify(mockDeps, times(1)).sendMessage(any(), any(Message.class));
+        assertNotNull(delayMessage);
+
+        final MdnsPacket queryPacket = MdnsPacket.parse(
+                new MdnsPacketReader(queryCaptor.getValue().get(0)));
+        final String[] subtypeLabels = Stream.concat(Stream.of("_subtype", "_sub"),
+                Arrays.stream(SERVICE_TYPE_LABELS)).toArray(String[]::new);
+        assertTrue(hasQuestion(queryPacket, MdnsRecord.TYPE_PTR, SERVICE_TYPE_LABELS));
+        assertTrue(hasQuestion(queryPacket, MdnsRecord.TYPE_PTR, subtypeLabels));
+
+        // Process a response
+        final String serviceName = "service-instance";
+        final String ipV4Address = "192.0.2.0";
+        final MdnsPacket packetWithoutSubtype = createResponse(
+                serviceName, ipV4Address, 5353, SERVICE_TYPE_LABELS,
+                Collections.emptyMap() /* textAttributes */, TEST_TTL);
+        final MdnsPointerRecord originalPtr = (MdnsPointerRecord) CollectionUtils.findFirst(
+                packetWithoutSubtype.answers, r -> r instanceof MdnsPointerRecord);
+
+        // Add a subtype PTR record
+        final ArrayList<MdnsRecord> newAnswers = new ArrayList<>(packetWithoutSubtype.answers);
+        newAnswers.add(new MdnsPointerRecord(subtypeLabels, originalPtr.getReceiptTime(),
+                originalPtr.getCacheFlush(), originalPtr.getTtl(), originalPtr.getPointer()));
+        final MdnsPacket packetWithSubtype = new MdnsPacket(
+                packetWithoutSubtype.flags,
+                packetWithoutSubtype.questions,
+                newAnswers,
+                packetWithoutSubtype.authorityRecords,
+                packetWithoutSubtype.additionalRecords);
+        processResponse(packetWithSubtype, socketKey);
+
+        // Expect a query with known answers
+        dispatchMessage();
+        final ArgumentCaptor<List<DatagramPacket>> knownAnswersQueryCaptor =
+                ArgumentCaptor.forClass(List.class);
+        currentThreadExecutor.getAndClearLastScheduledRunnable().run();
+        inOrder.verify(mockSocketClient, times(2)).sendPacketRequestingMulticastResponse(
+                knownAnswersQueryCaptor.capture(), eq(socketKey), eq(false));
+
+        final MdnsPacket knownAnswersQueryPacket = MdnsPacket.parse(
+                new MdnsPacketReader(knownAnswersQueryCaptor.getValue().get(0)));
+        assertTrue(hasQuestion(knownAnswersQueryPacket, MdnsRecord.TYPE_PTR, SERVICE_TYPE_LABELS));
+        assertTrue(hasQuestion(knownAnswersQueryPacket, MdnsRecord.TYPE_PTR, subtypeLabels));
+        assertTrue(hasAnswer(knownAnswersQueryPacket, MdnsRecord.TYPE_PTR, SERVICE_TYPE_LABELS));
+        assertTrue(hasAnswer(knownAnswersQueryPacket, MdnsRecord.TYPE_PTR, subtypeLabels));
+    }
+
     private static MdnsServiceInfo matchServiceName(String name) {
         return argThat(info -> info.getServiceInstanceName().equals(name));
     }
@@ -1943,17 +2083,21 @@
         currentThreadExecutor.getAndClearLastScheduledRunnable().run();
         if (expectsUnicastResponse) {
             verify(mockSocketClient).sendPacketRequestingUnicastResponse(
-                    expectedIPv4Packets[index], socketKey, false);
+                    argThat(pkts -> pkts.get(0).equals(expectedIPv4Packets[index])),
+                    eq(socketKey), eq(false));
             if (multipleSocketDiscovery) {
                 verify(mockSocketClient).sendPacketRequestingUnicastResponse(
-                        expectedIPv6Packets[index], socketKey, false);
+                        argThat(pkts -> pkts.get(0).equals(expectedIPv6Packets[index])),
+                        eq(socketKey), eq(false));
             }
         } else {
             verify(mockSocketClient).sendPacketRequestingMulticastResponse(
-                    expectedIPv4Packets[index], socketKey, false);
+                    argThat(pkts -> pkts.get(0).equals(expectedIPv4Packets[index])),
+                    eq(socketKey), eq(false));
             if (multipleSocketDiscovery) {
                 verify(mockSocketClient).sendPacketRequestingMulticastResponse(
-                        expectedIPv6Packets[index], socketKey, false);
+                        argThat(pkts -> pkts.get(0).equals(expectedIPv6Packets[index])),
+                        eq(socketKey), eq(false));
             }
         }
         verify(mockDeps, times(index + 1))
@@ -1982,6 +2126,12 @@
                 && (name == null || Arrays.equals(q.name, name)));
     }
 
+    private static boolean hasAnswer(MdnsPacket packet, int type, @NonNull String[] name) {
+        return packet.answers.stream().anyMatch(q -> {
+            return q.getType() == type && (Arrays.equals(q.name, name));
+        });
+    }
+
     // A fake ScheduledExecutorService that keeps tracking the last scheduled Runnable and its delay
     // time.
     private class FakeExecutor extends ScheduledThreadPoolExecutor {
diff --git a/tests/unit/java/com/android/server/connectivity/mdns/MdnsSocketClientTests.java b/tests/unit/java/com/android/server/connectivity/mdns/MdnsSocketClientTests.java
index 8b7ab71..1989ed3 100644
--- a/tests/unit/java/com/android/server/connectivity/mdns/MdnsSocketClientTests.java
+++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsSocketClientTests.java
@@ -26,14 +26,18 @@
 import static org.mockito.ArgumentMatchers.argThat;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.inOrder;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.timeout;
+import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
 import android.Manifest.permission;
 import android.annotation.RequiresPermission;
 import android.content.Context;
+import android.net.ConnectivityManager;
 import android.net.wifi.WifiManager;
 import android.net.wifi.WifiManager.MulticastLock;
 import android.text.format.DateUtils;
@@ -48,7 +52,9 @@
 import org.junit.Ignore;
 import org.junit.Test;
 import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
 import org.mockito.ArgumentMatchers;
+import org.mockito.InOrder;
 import org.mockito.Mock;
 import org.mockito.MockitoAnnotations;
 import org.mockito.invocation.InvocationOnMock;
@@ -56,6 +62,8 @@
 import java.io.IOException;
 import java.net.DatagramPacket;
 import java.net.InetSocketAddress;
+import java.util.ArrayList;
+import java.util.List;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicBoolean;
@@ -71,6 +79,7 @@
 
     @Mock private Context mContext;
     @Mock private WifiManager mockWifiManager;
+    @Mock private ConnectivityManager mockConnectivityManager;
     @Mock private MdnsSocket mockMulticastSocket;
     @Mock private MdnsSocket mockUnicastSocket;
     @Mock private MulticastLock mockMulticastLock;
@@ -84,6 +93,9 @@
     public void setup() throws RuntimeException, IOException {
         MockitoAnnotations.initMocks(this);
 
+        doReturn(mockConnectivityManager).when(mContext).getSystemService(
+                Context.CONNECTIVITY_SERVICE);
+
         when(mockWifiManager.createMulticastLock(ArgumentMatchers.anyString()))
                 .thenReturn(mockMulticastLock);
 
@@ -226,7 +238,7 @@
 
         // Sends a packet.
         DatagramPacket packet = getTestDatagramPacket();
-        mdnsClient.sendPacketRequestingMulticastResponse(packet,
+        mdnsClient.sendPacketRequestingMulticastResponse(List.of(packet),
                 false /* onlyUseIpv6OnIpv6OnlyNetworks */);
         // mockMulticastSocket.send() will be called on another thread. If we verify it immediately,
         // it may not be called yet. So timeout is added.
@@ -234,7 +246,7 @@
         verify(mockUnicastSocket, timeout(TIMEOUT).times(0)).send(packet);
 
         // Verify the packet is sent by the unicast socket.
-        mdnsClient.sendPacketRequestingUnicastResponse(packet,
+        mdnsClient.sendPacketRequestingUnicastResponse(List.of(packet),
                 false /* onlyUseIpv6OnIpv6OnlyNetworks */);
         verify(mockMulticastSocket, timeout(TIMEOUT).times(1)).send(packet);
         verify(mockUnicastSocket, timeout(TIMEOUT).times(1)).send(packet);
@@ -279,7 +291,7 @@
 
         // Sends a packet.
         DatagramPacket packet = getTestDatagramPacket();
-        mdnsClient.sendPacketRequestingMulticastResponse(packet,
+        mdnsClient.sendPacketRequestingMulticastResponse(List.of(packet),
                 false /* onlyUseIpv6OnIpv6OnlyNetworks */);
         // mockMulticastSocket.send() will be called on another thread. If we verify it immediately,
         // it may not be called yet. So timeout is added.
@@ -287,7 +299,7 @@
         verify(mockUnicastSocket, timeout(TIMEOUT).times(0)).send(packet);
 
         // Verify the packet is sent by the multicast socket as well.
-        mdnsClient.sendPacketRequestingUnicastResponse(packet,
+        mdnsClient.sendPacketRequestingUnicastResponse(List.of(packet),
                 false /* onlyUseIpv6OnIpv6OnlyNetworks */);
         verify(mockMulticastSocket, timeout(TIMEOUT).times(2)).send(packet);
         verify(mockUnicastSocket, timeout(TIMEOUT).times(0)).send(packet);
@@ -320,19 +332,25 @@
 
     @Test
     public void testStartStop() throws IOException {
-        for (int i = 0; i < 5; i++) {
+        for (int i = 1; i <= 5; i++) {
             mdnsClient.startDiscovery();
 
             Thread multicastReceiverThread = mdnsClient.multicastReceiveThread;
             Thread socketThread = mdnsClient.sendThread;
+            final ArgumentCaptor<ConnectivityManager.NetworkCallback> cbCaptor =
+                    ArgumentCaptor.forClass(ConnectivityManager.NetworkCallback.class);
 
             assertTrue(multicastReceiverThread.isAlive());
             assertTrue(socketThread.isAlive());
+            verify(mockConnectivityManager, times(i))
+                    .registerNetworkCallback(any(), cbCaptor.capture());
 
             mdnsClient.stopDiscovery();
 
             assertFalse(multicastReceiverThread.isAlive());
             assertFalse(socketThread.isAlive());
+            verify(mockConnectivityManager, times(i))
+                    .unregisterNetworkCallback(cbCaptor.getValue());
         }
     }
 
@@ -340,7 +358,7 @@
     public void testStopDiscovery_queueIsCleared() throws IOException {
         mdnsClient.startDiscovery();
         mdnsClient.stopDiscovery();
-        mdnsClient.sendPacketRequestingMulticastResponse(getTestDatagramPacket(),
+        mdnsClient.sendPacketRequestingMulticastResponse(List.of(getTestDatagramPacket()),
                 false /* onlyUseIpv6OnIpv6OnlyNetworks */);
 
         synchronized (mdnsClient.multicastPacketQueue) {
@@ -352,7 +370,7 @@
     public void testSendPacket_afterDiscoveryStops() throws IOException {
         mdnsClient.startDiscovery();
         mdnsClient.stopDiscovery();
-        mdnsClient.sendPacketRequestingMulticastResponse(getTestDatagramPacket(),
+        mdnsClient.sendPacketRequestingMulticastResponse(List.of(getTestDatagramPacket()),
                 false /* onlyUseIpv6OnIpv6OnlyNetworks */);
 
         synchronized (mdnsClient.multicastPacketQueue) {
@@ -366,7 +384,7 @@
         //MdnsConfigsFlagsImpl.mdnsPacketQueueMaxSize.override(2L);
         mdnsClient.startDiscovery();
         for (int i = 0; i < 100; i++) {
-            mdnsClient.sendPacketRequestingMulticastResponse(getTestDatagramPacket(),
+            mdnsClient.sendPacketRequestingMulticastResponse(List.of(getTestDatagramPacket()),
                     false /* onlyUseIpv6OnIpv6OnlyNetworks */);
         }
 
@@ -464,9 +482,9 @@
 
         mdnsClient.startDiscovery();
         DatagramPacket packet = getTestDatagramPacket();
-        mdnsClient.sendPacketRequestingUnicastResponse(packet,
+        mdnsClient.sendPacketRequestingUnicastResponse(List.of(packet),
                 false /* onlyUseIpv6OnIpv6OnlyNetworks */);
-        mdnsClient.sendPacketRequestingMulticastResponse(packet,
+        mdnsClient.sendPacketRequestingMulticastResponse(List.of(packet),
                 false /* onlyUseIpv6OnIpv6OnlyNetworks */);
 
         // Wait for the timer to be triggered.
@@ -497,9 +515,9 @@
         assertFalse(mdnsClient.receivedUnicastResponse);
         assertFalse(mdnsClient.cannotReceiveMulticastResponse.get());
 
-        mdnsClient.sendPacketRequestingUnicastResponse(packet,
+        mdnsClient.sendPacketRequestingUnicastResponse(List.of(packet),
                 false /* onlyUseIpv6OnIpv6OnlyNetworks */);
-        mdnsClient.sendPacketRequestingMulticastResponse(packet,
+        mdnsClient.sendPacketRequestingMulticastResponse(List.of(packet),
                 false /* onlyUseIpv6OnIpv6OnlyNetworks */);
         Thread.sleep(MdnsConfigs.checkMulticastResponseIntervalMs() * 2);
 
@@ -556,6 +574,26 @@
                 .onResponseReceived(any(), argThat(key -> key.getInterfaceIndex() == -1));
     }
 
+    @Test
+    public void testSendPacketWithMultipleDatagramPacket() throws IOException {
+        mdnsClient.startDiscovery();
+        final List<DatagramPacket> packets = new ArrayList<>();
+        for (int i = 0; i < 10; i++) {
+            packets.add(new DatagramPacket(new byte[10 + i] /* buff */, 0 /* offset */,
+                    10 + i /* length */, MdnsConstants.IPV4_SOCKET_ADDR));
+        }
+
+        // Sends packets.
+        mdnsClient.sendPacketRequestingMulticastResponse(packets,
+                false /* onlyUseIpv6OnIpv6OnlyNetworks */);
+        InOrder inOrder = inOrder(mockMulticastSocket);
+        for (int i = 0; i < 10; i++) {
+            // mockMulticastSocket.send() will be called on another thread. If we verify it
+            // immediately, it may not be called yet. So timeout is added.
+            inOrder.verify(mockMulticastSocket, timeout(TIMEOUT)).send(packets.get(i));
+        }
+    }
+
     private DatagramPacket getTestDatagramPacket() {
         return new DatagramPacket(buf, 0, 5,
                 new InetSocketAddress(MdnsConstants.getMdnsIPv4Address(), 5353 /* port */));
diff --git a/tests/unit/java/com/android/server/connectivity/mdns/util/MdnsUtilsTest.kt b/tests/unit/java/com/android/server/connectivity/mdns/util/MdnsUtilsTest.kt
index f705bcb..009205e 100644
--- a/tests/unit/java/com/android/server/connectivity/mdns/util/MdnsUtilsTest.kt
+++ b/tests/unit/java/com/android/server/connectivity/mdns/util/MdnsUtilsTest.kt
@@ -17,6 +17,13 @@
 package com.android.server.connectivity.mdns.util
 
 import android.os.Build
+import com.android.server.connectivity.mdns.MdnsConstants
+import com.android.server.connectivity.mdns.MdnsConstants.FLAG_TRUNCATED
+import com.android.server.connectivity.mdns.MdnsPacket
+import com.android.server.connectivity.mdns.MdnsPacketReader
+import com.android.server.connectivity.mdns.MdnsPointerRecord
+import com.android.server.connectivity.mdns.MdnsRecord
+import com.android.server.connectivity.mdns.util.MdnsUtils.createQueryDatagramPackets
 import com.android.server.connectivity.mdns.util.MdnsUtils.equalsDnsLabelIgnoreDnsCase
 import com.android.server.connectivity.mdns.util.MdnsUtils.equalsIgnoreDnsCase
 import com.android.server.connectivity.mdns.util.MdnsUtils.toDnsLabelsLowerCase
@@ -24,6 +31,8 @@
 import com.android.server.connectivity.mdns.util.MdnsUtils.truncateServiceName
 import com.android.testutils.DevSdkIgnoreRule
 import com.android.testutils.DevSdkIgnoreRunner
+import java.net.DatagramPacket
+import kotlin.test.assertContentEquals
 import org.junit.Assert.assertArrayEquals
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertFalse
@@ -43,19 +52,27 @@
         assertEquals("ţést", toDnsLowerCase("ţést"))
         // Unicode characters 0x10000 (𐀀), 0x10001 (𐀁), 0x10041 (𐁁)
         // Note the last 2 bytes of 0x10041 are identical to 'A', but it should remain unchanged.
-        assertEquals("test: -->\ud800\udc00 \ud800\udc01 \ud800\udc41<-- ",
-                toDnsLowerCase("Test: -->\ud800\udc00 \ud800\udc01 \ud800\udc41<-- "))
+        assertEquals(
+            "test: -->\ud800\udc00 \ud800\udc01 \ud800\udc41<-- ",
+                toDnsLowerCase("Test: -->\ud800\udc00 \ud800\udc01 \ud800\udc41<-- ")
+        )
         // Also test some characters where the first surrogate is not \ud800
-        assertEquals("test: >\ud83c\udff4\udb40\udc67\udb40\udc62\udb40" +
+        assertEquals(
+            "test: >\ud83c\udff4\udb40\udc67\udb40\udc62\udb40" +
                 "\udc77\udb40\udc6c\udb40\udc73\udb40\udc7f<",
-                toDnsLowerCase("Test: >\ud83c\udff4\udb40\udc67\udb40\udc62\udb40" +
-                        "\udc77\udb40\udc6c\udb40\udc73\udb40\udc7f<"))
+                toDnsLowerCase(
+                    "Test: >\ud83c\udff4\udb40\udc67\udb40\udc62\udb40" +
+                        "\udc77\udb40\udc6c\udb40\udc73\udb40\udc7f<"
+                )
+        )
     }
 
     @Test
     fun testToDnsLabelsLowerCase() {
-        assertArrayEquals(arrayOf("test", "tÉst", "ţést"),
-            toDnsLabelsLowerCase(arrayOf("TeSt", "TÉST", "ţést")))
+        assertArrayEquals(
+            arrayOf("test", "tÉst", "ţést"),
+            toDnsLabelsLowerCase(arrayOf("TeSt", "TÉST", "ţést"))
+        )
     }
 
     @Test
@@ -67,13 +84,17 @@
         assertFalse(equalsIgnoreDnsCase("ŢÉST", "ţést"))
         // Unicode characters 0x10000 (𐀀), 0x10001 (𐀁), 0x10041 (𐁁)
         // Note the last 2 bytes of 0x10041 are identical to 'A', but it should remain unchanged.
-        assertTrue(equalsIgnoreDnsCase("test: -->\ud800\udc00 \ud800\udc01 \ud800\udc41<-- ",
-                "Test: -->\ud800\udc00 \ud800\udc01 \ud800\udc41<-- "))
+        assertTrue(equalsIgnoreDnsCase(
+            "test: -->\ud800\udc00 \ud800\udc01 \ud800\udc41<-- ",
+                "Test: -->\ud800\udc00 \ud800\udc01 \ud800\udc41<-- "
+        ))
         // Also test some characters where the first surrogate is not \ud800
-        assertTrue(equalsIgnoreDnsCase("test: >\ud83c\udff4\udb40\udc67\udb40\udc62\udb40" +
+        assertTrue(equalsIgnoreDnsCase(
+            "test: >\ud83c\udff4\udb40\udc67\udb40\udc62\udb40" +
                 "\udc77\udb40\udc6c\udb40\udc73\udb40\udc7f<",
                 "Test: >\ud83c\udff4\udb40\udc67\udb40\udc62\udb40" +
-                        "\udc77\udb40\udc6c\udb40\udc73\udb40\udc7f<"))
+                        "\udc77\udb40\udc6c\udb40\udc73\udb40\udc7f<"
+        ))
     }
 
     @Test
@@ -92,14 +113,84 @@
 
     @Test
     fun testTypeEqualsOrIsSubtype() {
-        assertTrue(MdnsUtils.typeEqualsOrIsSubtype(arrayOf("_type", "_tcp", "local"),
-            arrayOf("_type", "_TCP", "local")))
-        assertTrue(MdnsUtils.typeEqualsOrIsSubtype(arrayOf("_type", "_tcp", "local"),
-            arrayOf("a", "_SUB", "_type", "_TCP", "local")))
-        assertFalse(MdnsUtils.typeEqualsOrIsSubtype(arrayOf("_sub", "_type", "_tcp", "local"),
-                arrayOf("_type", "_TCP", "local")))
+        assertTrue(MdnsUtils.typeEqualsOrIsSubtype(
+            arrayOf("_type", "_tcp", "local"),
+            arrayOf("_type", "_TCP", "local")
+        ))
+        assertTrue(MdnsUtils.typeEqualsOrIsSubtype(
+            arrayOf("_type", "_tcp", "local"),
+            arrayOf("a", "_SUB", "_type", "_TCP", "local")
+        ))
+        assertFalse(MdnsUtils.typeEqualsOrIsSubtype(
+            arrayOf("_sub", "_type", "_tcp", "local"),
+                arrayOf("_type", "_TCP", "local")
+        ))
         assertFalse(MdnsUtils.typeEqualsOrIsSubtype(
                 arrayOf("a", "_other", "_type", "_tcp", "local"),
-                arrayOf("a", "_SUB", "_type", "_TCP", "local")))
+                arrayOf("a", "_SUB", "_type", "_TCP", "local")
+        ))
+    }
+
+    @Test
+    fun testCreateQueryDatagramPackets() {
+        // Question data bytes:
+        // Name label(17)(duplicated labels) + PTR type(2) + cacheFlush(2) = 21
+        //
+        // Known answers data bytes:
+        // Name label(17)(duplicated labels) + PTR type(2) + cacheFlush(2) + receiptTimeMillis(4)
+        // + Data length(2) + Pointer data(18)(duplicated labels) = 45
+        val questions = mutableListOf<MdnsRecord>()
+        val knownAnswers = mutableListOf<MdnsRecord>()
+        for (i in 1..100) {
+            questions.add(MdnsPointerRecord(arrayOf("_testservice$i", "_tcp", "local"), false))
+            knownAnswers.add(MdnsPointerRecord(
+                    arrayOf("_testservice$i", "_tcp", "local"),
+                    0L,
+                    false,
+                    4_500_000L,
+                    arrayOf("MyTestService$i", "_testservice$i", "_tcp", "local")
+            ))
+        }
+        // MdnsPacket data bytes:
+        // Questions(21 * 100) + Answers(45 * 100) = 6600 -> at least 5 packets
+        val query = MdnsPacket(
+                MdnsConstants.FLAGS_QUERY,
+                questions as List<MdnsRecord>,
+                knownAnswers as List<MdnsRecord>,
+                emptyList(),
+                emptyList()
+        )
+        // Expect the oversize MdnsPacket to be separated into 5 DatagramPackets.
+        val bufferSize = 1500
+        val packets = createQueryDatagramPackets(
+                ByteArray(bufferSize),
+                query,
+                MdnsConstants.IPV4_SOCKET_ADDR
+        )
+        assertEquals(5, packets.size)
+        assertTrue(packets.all { packet -> packet.length < bufferSize })
+
+        val mdnsPacket = createMdnsPacketFromMultipleDatagramPackets(packets)
+        assertEquals(query.flags, mdnsPacket.flags)
+        assertContentEquals(query.questions, mdnsPacket.questions)
+        assertContentEquals(query.answers, mdnsPacket.answers)
+    }
+
+    private fun createMdnsPacketFromMultipleDatagramPackets(
+            packets: List<DatagramPacket>
+    ): MdnsPacket {
+        var flags = 0
+        val questions = mutableListOf<MdnsRecord>()
+        val answers = mutableListOf<MdnsRecord>()
+        for ((index, packet) in packets.withIndex()) {
+            val mdnsPacket = MdnsPacket.parse(MdnsPacketReader(packet))
+            if (index != packets.size - 1) {
+                assertTrue((mdnsPacket.flags and FLAG_TRUNCATED) == FLAG_TRUNCATED)
+            }
+            flags = mdnsPacket.flags
+            questions.addAll(mdnsPacket.questions)
+            answers.addAll(mdnsPacket.answers)
+        }
+        return MdnsPacket(flags, questions, answers, emptyList(), emptyList())
     }
 }
diff --git a/tests/unit/java/com/android/server/connectivityservice/CSCaptivePortalAppTest.kt b/tests/unit/java/com/android/server/connectivityservice/CSCaptivePortalAppTest.kt
index be2b29c..0bad60da 100644
--- a/tests/unit/java/com/android/server/connectivityservice/CSCaptivePortalAppTest.kt
+++ b/tests/unit/java/com/android/server/connectivityservice/CSCaptivePortalAppTest.kt
@@ -20,6 +20,7 @@
 import android.content.Intent
 import android.content.pm.PackageManager.PERMISSION_DENIED
 import android.content.pm.PackageManager.PERMISSION_GRANTED
+import android.net.CaptivePortal
 import android.net.ConnectivityManager.ACTION_CAPTIVE_PORTAL_SIGN_IN
 import android.net.ConnectivityManager.EXTRA_CAPTIVE_PORTAL
 import android.net.IpPrefix
@@ -33,23 +34,23 @@
 import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED
 import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_VCN_MANAGED
 import android.net.NetworkCapabilities.TRANSPORT_WIFI
-import android.net.NetworkStack
-import android.net.CaptivePortal
 import android.net.NetworkRequest
 import android.net.NetworkScore
 import android.net.NetworkScore.KEEP_CONNECTED_FOR_TEST
+import android.net.NetworkStack
 import android.net.RouteInfo
 import android.os.Build
 import android.os.Bundle
 import androidx.test.filters.SmallTest
 import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo
 import com.android.testutils.DevSdkIgnoreRunner
-import com.android.testutils.assertThrows
 import com.android.testutils.TestableNetworkCallback
+import kotlin.test.assertEquals
 import org.junit.Test
 import org.junit.runner.RunWith
+import org.mockito.ArgumentMatchers.anyInt
+import org.mockito.Mockito.never
 import org.mockito.Mockito.verify
-import kotlin.test.assertEquals
 
 // This allows keeping all the networks connected without having to file individual requests
 // for them.
@@ -95,16 +96,22 @@
         captivePortalCallback.expectAvailableCallbacksUnvalidated(wifiAgent)
         val signInIntent = startCaptivePortalApp(wifiAgent)
         // Remove the granted permissions
-        context.setPermission(NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK,
-                PERMISSION_DENIED)
+        context.setPermission(
+            NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK,
+                PERMISSION_DENIED
+        )
         context.setPermission(NETWORK_STACK, PERMISSION_DENIED)
         val captivePortal: CaptivePortal? = signInIntent.getParcelableExtra(EXTRA_CAPTIVE_PORTAL)
-        assertThrows(SecurityException::class.java, { captivePortal?.reevaluateNetwork() })
+        captivePortal?.reevaluateNetwork()
+        verify(wifiAgent.networkMonitor, never()).forceReevaluation(anyInt())
     }
 
     private fun createWifiAgent(): CSAgentWrapper {
-        return Agent(score = keepScore(), lp = lp(WIFI_IFACE),
-                nc = nc(TRANSPORT_WIFI, NET_CAPABILITY_INTERNET))
+        return Agent(
+            score = keepScore(),
+            lp = lp(WIFI_IFACE),
+                nc = nc(TRANSPORT_WIFI, NET_CAPABILITY_INTERNET)
+        )
     }
 
     private fun startCaptivePortalApp(networkAgent: CSAgentWrapper): Intent {
diff --git a/tests/unit/java/com/android/server/connectivityservice/CSFirewallChainTest.kt b/tests/unit/java/com/android/server/connectivityservice/CSFirewallChainTest.kt
new file mode 100644
index 0000000..16de4da
--- /dev/null
+++ b/tests/unit/java/com/android/server/connectivityservice/CSFirewallChainTest.kt
@@ -0,0 +1,157 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server
+
+import android.net.ConnectivityManager
+import android.os.Build
+import androidx.test.filters.SmallTest
+import com.android.server.connectivity.ConnectivityFlags.BACKGROUND_FIREWALL_CHAIN
+import com.android.testutils.DevSdkIgnoreRule
+import com.android.testutils.DevSdkIgnoreRule.IgnoreAfter
+import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo
+import com.android.testutils.DevSdkIgnoreRunner
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentMatchers.anyBoolean
+import org.mockito.ArgumentMatchers.anyInt
+import org.mockito.Mockito.any
+import org.mockito.Mockito.clearInvocations
+import org.mockito.Mockito.never
+import org.mockito.Mockito.verify
+
+@RunWith(DevSdkIgnoreRunner::class)
+@SmallTest
+@IgnoreUpTo(Build.VERSION_CODES.S_V2)
+class CSFirewallChainTest : CSTest() {
+    @get:Rule
+    val ignoreRule = DevSdkIgnoreRule()
+
+    // Tests for setFirewallChainEnabled on FIREWALL_CHAIN_BACKGROUND
+    @Test
+    @FeatureFlags(flags = [Flag(BACKGROUND_FIREWALL_CHAIN, false)])
+    fun setFirewallChainEnabled_backgroundChainDisabled() {
+        verifySetFirewallChainEnabledOnBackgroundDoesNothing()
+    }
+
+    @Test
+    @FeatureFlags(flags = [Flag(BACKGROUND_FIREWALL_CHAIN, true)])
+    @IgnoreUpTo(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+    fun setFirewallChainEnabled_backgroundChainEnabled_afterU() {
+        cm.setFirewallChainEnabled(ConnectivityManager.FIREWALL_CHAIN_BACKGROUND, true)
+        verify(bpfNetMaps).setChildChain(ConnectivityManager.FIREWALL_CHAIN_BACKGROUND, true)
+
+        clearInvocations(bpfNetMaps)
+
+        cm.setFirewallChainEnabled(ConnectivityManager.FIREWALL_CHAIN_BACKGROUND, false)
+        verify(bpfNetMaps).setChildChain(ConnectivityManager.FIREWALL_CHAIN_BACKGROUND, false)
+    }
+
+    @Test
+    @FeatureFlags(flags = [Flag(BACKGROUND_FIREWALL_CHAIN, true)])
+    @IgnoreAfter(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+    fun setFirewallChainEnabled_backgroundChainEnabled_uptoU() {
+        verifySetFirewallChainEnabledOnBackgroundDoesNothing()
+    }
+
+    private fun verifySetFirewallChainEnabledOnBackgroundDoesNothing() {
+        cm.setFirewallChainEnabled(ConnectivityManager.FIREWALL_CHAIN_BACKGROUND, true)
+        verify(bpfNetMaps, never()).setChildChain(anyInt(), anyBoolean())
+
+        cm.setFirewallChainEnabled(ConnectivityManager.FIREWALL_CHAIN_BACKGROUND, false)
+        verify(bpfNetMaps, never()).setChildChain(anyInt(), anyBoolean())
+    }
+
+    // Tests for replaceFirewallChain on FIREWALL_CHAIN_BACKGROUND
+    @Test
+    @FeatureFlags(flags = [Flag(BACKGROUND_FIREWALL_CHAIN, false)])
+    fun replaceFirewallChain_backgroundChainDisabled() {
+        verifyReplaceFirewallChainOnBackgroundDoesNothing()
+    }
+
+    @Test
+    @FeatureFlags(flags = [Flag(BACKGROUND_FIREWALL_CHAIN, true)])
+    @IgnoreUpTo(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+    fun replaceFirewallChain_backgroundChainEnabled_afterU() {
+        val uids = intArrayOf(53, 42, 79)
+        cm.replaceFirewallChain(ConnectivityManager.FIREWALL_CHAIN_BACKGROUND, uids)
+        verify(bpfNetMaps).replaceUidChain(ConnectivityManager.FIREWALL_CHAIN_BACKGROUND, uids)
+    }
+
+    @Test
+    @FeatureFlags(flags = [Flag(BACKGROUND_FIREWALL_CHAIN, true)])
+    @IgnoreAfter(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+    fun replaceFirewallChain_backgroundChainEnabled_uptoU() {
+        verifyReplaceFirewallChainOnBackgroundDoesNothing()
+    }
+
+    private fun verifyReplaceFirewallChainOnBackgroundDoesNothing() {
+        val uids = intArrayOf(53, 42, 79)
+        cm.replaceFirewallChain(ConnectivityManager.FIREWALL_CHAIN_BACKGROUND, uids)
+        verify(bpfNetMaps, never()).replaceUidChain(anyInt(), any(IntArray::class.java))
+    }
+
+    // Tests for setUidFirewallRule on FIREWALL_CHAIN_BACKGROUND
+    @Test
+    @FeatureFlags(flags = [Flag(BACKGROUND_FIREWALL_CHAIN, false)])
+    fun setUidFirewallRule_backgroundChainDisabled() {
+        verifySetUidFirewallRuleOnBackgroundDoesNothing()
+    }
+
+    @Test
+    @FeatureFlags(flags = [Flag(BACKGROUND_FIREWALL_CHAIN, true)])
+    @IgnoreUpTo(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+    fun setUidFirewallRule_backgroundChainEnabled_afterU() {
+        val uid = 2345
+
+        cm.setUidFirewallRule(ConnectivityManager.FIREWALL_CHAIN_BACKGROUND, uid,
+            ConnectivityManager.FIREWALL_RULE_DEFAULT)
+        verify(bpfNetMaps).setUidRule(ConnectivityManager.FIREWALL_CHAIN_BACKGROUND, uid,
+            ConnectivityManager.FIREWALL_RULE_DENY)
+
+        clearInvocations(bpfNetMaps)
+
+        cm.setUidFirewallRule(ConnectivityManager.FIREWALL_CHAIN_BACKGROUND, uid,
+            ConnectivityManager.FIREWALL_RULE_DENY)
+        verify(bpfNetMaps).setUidRule(ConnectivityManager.FIREWALL_CHAIN_BACKGROUND, uid,
+            ConnectivityManager.FIREWALL_RULE_DENY)
+
+        clearInvocations(bpfNetMaps)
+
+        cm.setUidFirewallRule(ConnectivityManager.FIREWALL_CHAIN_BACKGROUND, uid,
+            ConnectivityManager.FIREWALL_RULE_ALLOW)
+        verify(bpfNetMaps).setUidRule(ConnectivityManager.FIREWALL_CHAIN_BACKGROUND, uid,
+            ConnectivityManager.FIREWALL_RULE_ALLOW)
+    }
+
+    @Test
+    @FeatureFlags(flags = [Flag(BACKGROUND_FIREWALL_CHAIN, true)])
+    @IgnoreAfter(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+    fun setUidFirewallRule_backgroundChainEnabled_uptoU() {
+        verifySetUidFirewallRuleOnBackgroundDoesNothing()
+    }
+
+    private fun verifySetUidFirewallRuleOnBackgroundDoesNothing() {
+        val uid = 2345
+
+        listOf(ConnectivityManager.FIREWALL_RULE_DEFAULT, ConnectivityManager.FIREWALL_RULE_ALLOW,
+            ConnectivityManager.FIREWALL_RULE_DENY).forEach { rule ->
+            cm.setUidFirewallRule(ConnectivityManager.FIREWALL_CHAIN_BACKGROUND, uid, rule)
+            verify(bpfNetMaps, never()).setUidRule(anyInt(), anyInt(), anyInt())
+        }
+    }
+}
diff --git a/tests/unit/java/com/android/server/connectivityservice/CSIngressDiscardRuleTests.kt b/tests/unit/java/com/android/server/connectivityservice/CSIngressDiscardRuleTests.kt
new file mode 100644
index 0000000..bb7fb51
--- /dev/null
+++ b/tests/unit/java/com/android/server/connectivityservice/CSIngressDiscardRuleTests.kt
@@ -0,0 +1,307 @@
+/*
+ * 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.InetAddresses
+import android.net.LinkAddress
+import android.net.LinkProperties
+import android.net.NetworkCapabilities
+import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET
+import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_VCN_MANAGED
+import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_VPN
+import android.net.NetworkCapabilities.TRANSPORT_VPN
+import android.net.NetworkCapabilities.TRANSPORT_WIFI
+import android.net.NetworkRequest
+import android.net.VpnManager.TYPE_VPN_SERVICE
+import android.net.VpnTransportInfo
+import android.os.Build
+import androidx.test.filters.SmallTest
+import com.android.server.connectivity.ConnectivityFlags
+import com.android.testutils.DevSdkIgnoreRule
+import com.android.testutils.DevSdkIgnoreRunner
+import com.android.testutils.RecorderCallback.CallbackEntry.LinkPropertiesChanged
+import com.android.testutils.TestableNetworkCallback
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.inOrder
+import org.mockito.Mockito.never
+import org.mockito.Mockito.timeout
+import org.mockito.Mockito.verify
+
+private const val VPN_IFNAME = "tun10041"
+private const val VPN_IFNAME2 = "tun10042"
+private const val WIFI_IFNAME = "wlan0"
+private const val TIMEOUT_MS = 1_000L
+private const val LONG_TIMEOUT_MS = 5_000
+
+private fun vpnNc() = NetworkCapabilities.Builder()
+        .addTransportType(TRANSPORT_VPN)
+        .removeCapability(NET_CAPABILITY_NOT_VPN)
+        .addCapability(NET_CAPABILITY_NOT_VCN_MANAGED)
+        .setTransportInfo(
+                VpnTransportInfo(
+                        TYPE_VPN_SERVICE,
+                        "MySession12345",
+                        false /* bypassable */,
+                        false /* longLivedTcpConnectionsExpensive */
+                )
+        )
+        .build()
+
+private fun wifiNc() = NetworkCapabilities.Builder()
+        .addTransportType(TRANSPORT_WIFI)
+        .addCapability(NET_CAPABILITY_INTERNET)
+        .addCapability(NET_CAPABILITY_NOT_VCN_MANAGED)
+        .build()
+
+private fun nr(transport: Int) = NetworkRequest.Builder()
+        .clearCapabilities()
+        .addTransportType(transport).apply {
+            if (transport != TRANSPORT_VPN) {
+                addCapability(NET_CAPABILITY_NOT_VPN)
+            }
+        }.build()
+
+private fun lp(iface: String, vararg linkAddresses: LinkAddress) = LinkProperties().apply {
+    interfaceName = iface
+    for (linkAddress in linkAddresses) {
+        addLinkAddress(linkAddress)
+    }
+}
+
+@RunWith(DevSdkIgnoreRunner::class)
+@SmallTest
+@DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.S_V2)
+class CSIngressDiscardRuleTests : CSTest() {
+    private val IPV6_ADDRESS = InetAddresses.parseNumericAddress("2001:db8:1::1")
+    private val IPV6_LINK_ADDRESS = LinkAddress(IPV6_ADDRESS, 64)
+    private val IPV6_ADDRESS2 = InetAddresses.parseNumericAddress("2001:db8:1::2")
+    private val IPV6_LINK_ADDRESS2 = LinkAddress(IPV6_ADDRESS2, 64)
+    private val IPV6_ADDRESS3 = InetAddresses.parseNumericAddress("2001:db8:1::3")
+    private val IPV6_LINK_ADDRESS3 = LinkAddress(IPV6_ADDRESS3, 64)
+    private val LOCAL_IPV6_ADDRRESS = InetAddresses.parseNumericAddress("fe80::1234")
+    private val LOCAL_IPV6_LINK_ADDRRESS = LinkAddress(LOCAL_IPV6_ADDRRESS, 64)
+
+    @Test
+    fun testVpnIngressDiscardRule_UpdateVpnAddress() {
+        // non-VPN network whose address will be not duplicated with VPN address
+        val wifiNc = wifiNc()
+        val wifiLp = lp(WIFI_IFNAME, IPV6_LINK_ADDRESS3)
+        val wifiAgent = Agent(nc = wifiNc, lp = wifiLp)
+        wifiAgent.connect()
+
+        val nr = nr(TRANSPORT_VPN)
+        val cb = TestableNetworkCallback()
+        cm.registerNetworkCallback(nr, cb)
+        val nc = vpnNc()
+        val lp = lp(VPN_IFNAME, IPV6_LINK_ADDRESS, LOCAL_IPV6_LINK_ADDRRESS)
+        val agent = Agent(nc = nc, lp = lp)
+        agent.connect()
+        cb.expectAvailableCallbacks(agent.network, validated = false)
+
+        // IngressDiscardRule is added to the VPN address
+        verify(bpfNetMaps).setIngressDiscardRule(IPV6_ADDRESS, VPN_IFNAME)
+        verify(bpfNetMaps, never()).setIngressDiscardRule(LOCAL_IPV6_ADDRRESS, VPN_IFNAME)
+
+        // The VPN address is changed
+        val newLp = lp(VPN_IFNAME, IPV6_LINK_ADDRESS2, LOCAL_IPV6_LINK_ADDRRESS)
+        agent.sendLinkProperties(newLp)
+        cb.expect<LinkPropertiesChanged>(agent.network)
+
+        // IngressDiscardRule is removed from the old VPN address and added to the new VPN address
+        verify(bpfNetMaps).removeIngressDiscardRule(IPV6_ADDRESS)
+        verify(bpfNetMaps).setIngressDiscardRule(IPV6_ADDRESS2, VPN_IFNAME)
+        verify(bpfNetMaps, never()).setIngressDiscardRule(LOCAL_IPV6_ADDRRESS, VPN_IFNAME)
+
+        agent.disconnect()
+        verify(bpfNetMaps, timeout(TIMEOUT_MS)).removeIngressDiscardRule(IPV6_ADDRESS2)
+
+        cm.unregisterNetworkCallback(cb)
+    }
+
+    @Test
+    fun testVpnIngressDiscardRule_UpdateInterfaceName() {
+        val inorder = inOrder(bpfNetMaps)
+
+        val nr = nr(TRANSPORT_VPN)
+        val cb = TestableNetworkCallback()
+        cm.registerNetworkCallback(nr, cb)
+        val nc = vpnNc()
+        val lp = lp(VPN_IFNAME, IPV6_LINK_ADDRESS, LOCAL_IPV6_LINK_ADDRRESS)
+        val agent = Agent(nc = nc, lp = lp)
+        agent.connect()
+        cb.expectAvailableCallbacks(agent.network, validated = false)
+
+        // IngressDiscardRule is added to the VPN address
+        inorder.verify(bpfNetMaps).setIngressDiscardRule(IPV6_ADDRESS, VPN_IFNAME)
+        inorder.verifyNoMoreInteractions()
+
+        // The VPN interface name is changed
+        val newlp = lp(VPN_IFNAME2, IPV6_LINK_ADDRESS, LOCAL_IPV6_LINK_ADDRRESS)
+        agent.sendLinkProperties(newlp)
+        cb.expect<LinkPropertiesChanged>(agent.network)
+
+        // IngressDiscardRule is updated with the new interface name
+        inorder.verify(bpfNetMaps).setIngressDiscardRule(IPV6_ADDRESS, VPN_IFNAME2)
+        inorder.verifyNoMoreInteractions()
+
+        agent.disconnect()
+        inorder.verify(bpfNetMaps, timeout(TIMEOUT_MS)).removeIngressDiscardRule(IPV6_ADDRESS)
+
+        cm.unregisterNetworkCallback(cb)
+    }
+
+    @Test
+    fun testVpnIngressDiscardRule_DuplicatedIpAddress_UpdateVpnAddress() {
+        val inorder = inOrder(bpfNetMaps)
+
+        val wifiNc = wifiNc()
+        val wifiLp = lp(WIFI_IFNAME, IPV6_LINK_ADDRESS, LOCAL_IPV6_LINK_ADDRRESS)
+        val wifiAgent = Agent(nc = wifiNc, lp = wifiLp)
+        wifiAgent.connect()
+
+        // IngressDiscardRule is not added to non-VPN interfaces
+        inorder.verify(bpfNetMaps, never()).setIngressDiscardRule(any(), any())
+
+        val nr = nr(TRANSPORT_VPN)
+        val cb = TestableNetworkCallback()
+        cm.requestNetwork(nr, cb)
+        val vpnNc = vpnNc()
+        val vpnLp = lp(VPN_IFNAME, IPV6_LINK_ADDRESS, LOCAL_IPV6_LINK_ADDRRESS)
+        val vpnAgent = Agent(nc = vpnNc, lp = vpnLp)
+        vpnAgent.connect()
+        cb.expectAvailableCallbacks(vpnAgent.network, validated = false)
+
+        // IngressDiscardRule is not added since the VPN address is duplicated with the Wi-Fi
+        // address
+        inorder.verify(bpfNetMaps, never()).setIngressDiscardRule(any(), any())
+
+        // The VPN address is changed to a different address from the Wi-Fi interface
+        val newVpnlp = lp(VPN_IFNAME, IPV6_LINK_ADDRESS2, LOCAL_IPV6_LINK_ADDRRESS)
+        vpnAgent.sendLinkProperties(newVpnlp)
+
+        // IngressDiscardRule is added to the VPN address since the VPN address is not duplicated
+        // with the Wi-Fi address
+        cb.expect<LinkPropertiesChanged>(vpnAgent.network)
+        inorder.verify(bpfNetMaps).setIngressDiscardRule(IPV6_ADDRESS2, VPN_IFNAME)
+
+        // The VPN address is changed back to the same address as the Wi-Fi interface
+        vpnAgent.sendLinkProperties(vpnLp)
+        cb.expect<LinkPropertiesChanged>(vpnAgent.network)
+
+        // IngressDiscardRule for IPV6_ADDRESS2 is removed but IngressDiscardRule for
+        // IPV6_LINK_ADDRESS is not added since Wi-Fi also uses IPV6_LINK_ADDRESS
+        inorder.verify(bpfNetMaps).removeIngressDiscardRule(IPV6_ADDRESS2)
+        inorder.verifyNoMoreInteractions()
+
+        vpnAgent.disconnect()
+        inorder.verifyNoMoreInteractions()
+
+        cm.unregisterNetworkCallback(cb)
+    }
+
+    @Test
+    fun testVpnIngressDiscardRule_DuplicatedIpAddress_UpdateNonVpnAddress() {
+        val inorder = inOrder(bpfNetMaps)
+
+        val vpnNc = vpnNc()
+        val vpnLp = lp(VPN_IFNAME, IPV6_LINK_ADDRESS, LOCAL_IPV6_LINK_ADDRRESS)
+        val vpnAgent = Agent(nc = vpnNc, lp = vpnLp)
+        vpnAgent.connect()
+
+        // IngressDiscardRule is added to the VPN address
+        inorder.verify(bpfNetMaps).setIngressDiscardRule(IPV6_ADDRESS, VPN_IFNAME)
+        inorder.verifyNoMoreInteractions()
+
+        val nr = nr(TRANSPORT_WIFI)
+        val cb = TestableNetworkCallback()
+        cm.requestNetwork(nr, cb)
+        val wifiNc = wifiNc()
+        val wifiLp = lp(WIFI_IFNAME, IPV6_LINK_ADDRESS, LOCAL_IPV6_LINK_ADDRRESS)
+        val wifiAgent = Agent(nc = wifiNc, lp = wifiLp)
+        wifiAgent.connect()
+        cb.expectAvailableCallbacks(wifiAgent.network, validated = false)
+
+        // IngressDiscardRule is removed since the VPN address is duplicated with the Wi-Fi address
+        inorder.verify(bpfNetMaps).removeIngressDiscardRule(IPV6_ADDRESS)
+
+        // The Wi-Fi address is changed to a different address from the VPN interface
+        val newWifilp = lp(WIFI_IFNAME, IPV6_LINK_ADDRESS2, LOCAL_IPV6_LINK_ADDRRESS)
+        wifiAgent.sendLinkProperties(newWifilp)
+        cb.expect<LinkPropertiesChanged>(wifiAgent.network)
+
+        // IngressDiscardRule is added to the VPN address since the VPN address is not duplicated
+        // with the Wi-Fi address
+        inorder.verify(bpfNetMaps).setIngressDiscardRule(IPV6_ADDRESS, VPN_IFNAME)
+        inorder.verifyNoMoreInteractions()
+
+        // The Wi-Fi address is changed back to the same address as the VPN interface
+        wifiAgent.sendLinkProperties(wifiLp)
+        cb.expect<LinkPropertiesChanged>(wifiAgent.network)
+
+        // IngressDiscardRule is removed since the VPN address is duplicated with the Wi-Fi address
+        inorder.verify(bpfNetMaps).removeIngressDiscardRule(IPV6_ADDRESS)
+
+        // IngressDiscardRule is added to the VPN address since Wi-Fi is disconnected
+        wifiAgent.disconnect()
+        inorder.verify(bpfNetMaps, timeout(TIMEOUT_MS))
+                .setIngressDiscardRule(IPV6_ADDRESS, VPN_IFNAME)
+
+        vpnAgent.disconnect()
+        inorder.verify(bpfNetMaps, timeout(TIMEOUT_MS)).removeIngressDiscardRule(IPV6_ADDRESS)
+
+        cm.unregisterNetworkCallback(cb)
+    }
+
+    @Test
+    fun testVpnIngressDiscardRule_UnregisterAfterReplacement() {
+        val wifiNc = wifiNc()
+        val wifiLp = lp(WIFI_IFNAME, IPV6_LINK_ADDRESS, LOCAL_IPV6_LINK_ADDRRESS)
+        val wifiAgent = Agent(nc = wifiNc, lp = wifiLp)
+        wifiAgent.connect()
+        wifiAgent.unregisterAfterReplacement(LONG_TIMEOUT_MS)
+        waitForIdle()
+
+        val vpnNc = vpnNc()
+        val vpnLp = lp(VPN_IFNAME, IPV6_LINK_ADDRESS, LOCAL_IPV6_LINK_ADDRRESS)
+        val vpnAgent = Agent(nc = vpnNc, lp = vpnLp)
+        vpnAgent.connect()
+
+        // IngressDiscardRule is added since the Wi-Fi network is destroyed
+        verify(bpfNetMaps).setIngressDiscardRule(IPV6_ADDRESS, VPN_IFNAME)
+
+        // IngressDiscardRule is removed since the VPN network is destroyed
+        vpnAgent.unregisterAfterReplacement(LONG_TIMEOUT_MS)
+        waitForIdle()
+        verify(bpfNetMaps).removeIngressDiscardRule(IPV6_ADDRESS)
+    }
+
+    @Test @FeatureFlags([Flag(ConnectivityFlags.INGRESS_TO_VPN_ADDRESS_FILTERING, false)])
+    fun testVpnIngressDiscardRule_FeatureDisabled() {
+        val nr = nr(TRANSPORT_VPN)
+        val cb = TestableNetworkCallback()
+        cm.registerNetworkCallback(nr, cb)
+        val nc = vpnNc()
+        val lp = lp(VPN_IFNAME, IPV6_LINK_ADDRESS, LOCAL_IPV6_LINK_ADDRRESS)
+        val agent = Agent(nc = nc, lp = lp)
+        agent.connect()
+        cb.expectAvailableCallbacks(agent.network, validated = false)
+
+        // IngressDiscardRule should not be added since feature is disabled
+        verify(bpfNetMaps, never()).setIngressDiscardRule(any(), any())
+    }
+}
diff --git a/tests/unit/java/com/android/server/connectivityservice/CSLocalAgentTests.kt b/tests/unit/java/com/android/server/connectivityservice/CSLocalAgentTests.kt
index c1730a4..83fff87 100644
--- a/tests/unit/java/com/android/server/connectivityservice/CSLocalAgentTests.kt
+++ b/tests/unit/java/com/android/server/connectivityservice/CSLocalAgentTests.kt
@@ -38,6 +38,7 @@
 import android.net.NetworkScore.KEEP_CONNECTED_FOR_TEST
 import android.net.NetworkScore.KEEP_CONNECTED_LOCAL_NETWORK
 import android.net.RouteInfo
+import android.net.connectivity.ConnectivityCompatChanges.ENABLE_MATCH_LOCAL_NETWORK
 import android.os.Build
 import com.android.testutils.DevSdkIgnoreRule
 import com.android.testutils.DevSdkIgnoreRunner
@@ -47,12 +48,15 @@
 import kotlin.test.assertFailsWith
 import org.junit.Test
 import org.junit.runner.RunWith
+import org.mockito.ArgumentMatchers.any
 import org.mockito.Mockito.clearInvocations
 import org.mockito.Mockito.eq
 import org.mockito.Mockito.inOrder
 import org.mockito.Mockito.never
 import org.mockito.Mockito.timeout
+import org.mockito.Mockito.times
 import org.mockito.Mockito.verify
+import org.mockito.Mockito.verifyNoMoreInteractions
 
 private const val TIMEOUT_MS = 200L
 private const val MEDIUM_TIMEOUT_MS = 1_000L
@@ -88,10 +92,10 @@
 class CSLocalAgentTests : CSTest() {
     val multicastRoutingConfigMinScope =
                 MulticastRoutingConfig.Builder(MulticastRoutingConfig.FORWARD_WITH_MIN_SCOPE, 4)
-                .build();
+                .build()
     val multicastRoutingConfigSelected =
                 MulticastRoutingConfig.Builder(MulticastRoutingConfig.FORWARD_SELECTED)
-                .build();
+                .build()
     val upstreamSelectorAny = NetworkRequest.Builder()
                 .addForbiddenCapability(NET_CAPABILITY_LOCAL_NETWORK)
                 .build()
@@ -205,6 +209,9 @@
                 nc = nc(TRANSPORT_THREAD, NET_CAPABILITY_LOCAL_NETWORK),
                 lp = lp(name),
                 lnc = localNetworkConfig,
+                score = FromS(NetworkScore.Builder()
+                        .setKeepConnectedReason(KEEP_CONNECTED_LOCAL_NETWORK)
+                        .build())
         )
         return localAgent
     }
@@ -219,9 +226,12 @@
                 nc = nc(TRANSPORT_CELLULAR, NET_CAPABILITY_INTERNET))
     }
 
-    private fun sendLocalNetworkConfig(localAgent: CSAgentWrapper,
-                upstreamSelector: NetworkRequest?, upstreamConfig: MulticastRoutingConfig,
-                downstreamConfig: MulticastRoutingConfig) {
+    private fun sendLocalNetworkConfig(
+            localAgent: CSAgentWrapper,
+            upstreamSelector: NetworkRequest?,
+            upstreamConfig: MulticastRoutingConfig,
+            downstreamConfig: MulticastRoutingConfig
+    ) {
         val newLnc = LocalNetworkConfig.Builder()
                 .setUpstreamSelector(upstreamSelector)
                 .setUpstreamMulticastRoutingConfig(upstreamConfig)
@@ -458,7 +468,6 @@
         wifiAgent.disconnect()
     }
 
-
     @Test
     fun testUnregisterUpstreamAfterReplacement_SameIfaceName() {
         doTestUnregisterUpstreamAfterReplacement(true)
@@ -824,4 +833,59 @@
 
         listenCb.expect<Lost>()
     }
+
+    fun doTestLocalNetworkRequest(
+            request: NetworkRequest,
+            enableMatchLocalNetwork: Boolean,
+            expectCallback: Boolean
+    ) {
+        deps.setBuildSdk(VERSION_V)
+        deps.setChangeIdEnabled(enableMatchLocalNetwork, ENABLE_MATCH_LOCAL_NETWORK)
+
+        val requestCb = TestableNetworkCallback()
+        val listenCb = TestableNetworkCallback()
+        cm.requestNetwork(request, requestCb)
+        cm.registerNetworkCallback(request, listenCb)
+
+        val localAgent = createLocalAgent("local0", FromS(LocalNetworkConfig.Builder().build()))
+        localAgent.connect()
+
+        if (expectCallback) {
+            requestCb.expectAvailableCallbacks(localAgent.network, validated = false)
+            listenCb.expectAvailableCallbacks(localAgent.network, validated = false)
+        } else {
+            waitForIdle()
+            requestCb.assertNoCallback(timeoutMs = 0)
+            listenCb.assertNoCallback(timeoutMs = 0)
+        }
+        localAgent.disconnect()
+    }
+
+    @Test
+    fun testLocalNetworkRequest() {
+        val request = NetworkRequest.Builder().build()
+        // If ENABLE_MATCH_LOCAL_NETWORK is false, request is not satisfied by local network
+        doTestLocalNetworkRequest(
+                request,
+                enableMatchLocalNetwork = false,
+                expectCallback = false)
+        // If ENABLE_MATCH_LOCAL_NETWORK is true, request is satisfied by local network
+        doTestLocalNetworkRequest(
+                request,
+                enableMatchLocalNetwork = true,
+                expectCallback = true)
+    }
+
+    @Test
+    fun testLocalNetworkRequest_withCapability() {
+        val request = NetworkRequest.Builder().addCapability(NET_CAPABILITY_LOCAL_NETWORK).build()
+        doTestLocalNetworkRequest(
+                request,
+                enableMatchLocalNetwork = false,
+                expectCallback = true)
+        doTestLocalNetworkRequest(
+                request,
+                enableMatchLocalNetwork = true,
+                expectCallback = true)
+    }
 }
diff --git a/tests/unit/java/com/android/server/connectivityservice/base/CSAgentWrapper.kt b/tests/unit/java/com/android/server/connectivityservice/base/CSAgentWrapper.kt
index d7343b1..13c5cbc 100644
--- a/tests/unit/java/com/android/server/connectivityservice/base/CSAgentWrapper.kt
+++ b/tests/unit/java/com/android/server/connectivityservice/base/CSAgentWrapper.kt
@@ -28,6 +28,7 @@
 import android.net.NetworkAgent
 import android.net.NetworkAgentConfig
 import android.net.NetworkCapabilities
+import android.net.NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK
 import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED
 import android.net.NetworkCapabilities.TRANSPORT_CELLULAR
 import android.net.NetworkProvider
@@ -39,6 +40,9 @@
 import com.android.testutils.RecorderCallback.CallbackEntry.Available
 import com.android.testutils.RecorderCallback.CallbackEntry.Lost
 import com.android.testutils.TestableNetworkCallback
+import java.util.concurrent.atomic.AtomicInteger
+import kotlin.test.assertEquals
+import kotlin.test.fail
 import org.mockito.ArgumentCaptor
 import org.mockito.ArgumentMatchers.any
 import org.mockito.ArgumentMatchers.anyInt
@@ -46,9 +50,6 @@
 import org.mockito.Mockito.doNothing
 import org.mockito.Mockito.verify
 import org.mockito.stubbing.Answer
-import java.util.concurrent.atomic.AtomicInteger
-import kotlin.test.assertEquals
-import kotlin.test.fail
 
 const val SHORT_TIMEOUT_MS = 200L
 
@@ -140,6 +141,9 @@
         val request = NetworkRequest.Builder().apply {
             clearCapabilities()
             if (nc.transportTypes.isNotEmpty()) addTransportType(nc.transportTypes[0])
+            if (nc.hasCapability(NET_CAPABILITY_LOCAL_NETWORK)) {
+                addCapability(NET_CAPABILITY_LOCAL_NETWORK)
+            }
         }.build()
         val cb = TestableNetworkCallback()
         mgr.registerNetworkCallback(request, cb)
@@ -166,6 +170,9 @@
         val request = NetworkRequest.Builder().apply {
             clearCapabilities()
             if (nc.transportTypes.isNotEmpty()) addTransportType(nc.transportTypes[0])
+            if (nc.hasCapability(NET_CAPABILITY_LOCAL_NETWORK)) {
+                addCapability(NET_CAPABILITY_LOCAL_NETWORK)
+            }
         }.build()
         val cb = TestableNetworkCallback(timeoutMs = SHORT_TIMEOUT_MS)
         mgr.registerNetworkCallback(request, cb)
@@ -178,6 +185,7 @@
 
     fun sendLocalNetworkConfig(lnc: LocalNetworkConfig) = agent.sendLocalNetworkConfig(lnc)
     fun sendNetworkCapabilities(nc: NetworkCapabilities) = agent.sendNetworkCapabilities(nc)
+    fun sendLinkProperties(lp: LinkProperties) = agent.sendLinkProperties(lp)
 
     fun connectWithCaptivePortal(redirectUrl: String) {
         setCaptivePortal(redirectUrl)
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 595ca47..3b06ad0 100644
--- a/tests/unit/java/com/android/server/connectivityservice/base/CSTest.kt
+++ b/tests/unit/java/com/android/server/connectivityservice/base/CSTest.kt
@@ -17,6 +17,7 @@
 package com.android.server
 
 import android.app.AlarmManager
+import android.app.AppOpsManager
 import android.content.BroadcastReceiver
 import android.content.Context
 import android.content.Intent
@@ -42,6 +43,7 @@
 import android.net.NetworkProvider
 import android.net.NetworkScore
 import android.net.PacProxyManager
+import android.net.connectivity.ConnectivityCompatChanges.ENABLE_MATCH_LOCAL_NETWORK
 import android.net.networkstack.NetworkStackClientBase
 import android.os.BatteryStatsManager
 import android.os.Bundle
@@ -51,9 +53,9 @@
 import android.os.UserHandle
 import android.os.UserManager
 import android.permission.PermissionManager.PermissionResult
+import android.telephony.SubscriptionManager
 import android.telephony.TelephonyManager
 import android.testing.TestableContext
-import android.util.ArraySet
 import androidx.test.platform.app.InstrumentationRegistry
 import com.android.internal.app.IBatteryStats
 import com.android.internal.util.test.BroadcastInterceptingContext
@@ -75,13 +77,17 @@
 import java.util.concurrent.Executors
 import java.util.concurrent.LinkedBlockingQueue
 import java.util.concurrent.TimeUnit
-import java.util.function.Consumer
 import java.util.function.BiConsumer
+import java.util.function.Consumer
+import kotlin.annotation.AnnotationRetention.RUNTIME
+import kotlin.annotation.AnnotationTarget.FUNCTION
 import kotlin.test.assertNotNull
 import kotlin.test.assertNull
 import kotlin.test.fail
 import org.junit.After
 import org.junit.Before
+import org.junit.Rule
+import org.junit.rules.TestName
 import org.mockito.AdditionalAnswers.delegatesTo
 import org.mockito.Mockito.doAnswer
 import org.mockito.Mockito.doReturn
@@ -103,6 +109,8 @@
 internal const val VERSION_V = 5
 internal const val VERSION_MAX = VERSION_V
 
+internal const val CALLING_UID_UNMOCKED = Process.INVALID_UID
+
 private fun NetworkCapabilities.getLegacyType() =
         when (transportTypes.getOrElse(0) { TRANSPORT_WIFI }) {
             TRANSPORT_BLUETOOTH -> ConnectivityManager.TYPE_BLUETOOTH
@@ -122,14 +130,19 @@
 // TODO (b/272685721) : make ConnectivityServiceTest smaller and faster by moving the setup
 // parts into this class and moving the individual tests to multiple separate classes.
 open class CSTest {
+    @get:Rule
+    val testNameRule = TestName()
+
     companion object {
         val CSTestExecutor = Executors.newSingleThreadExecutor()
     }
 
     init {
         if (!SdkLevel.isAtLeastS()) {
-            throw UnsupportedApiLevelException("CSTest subclasses must be annotated to only " +
-                    "run on S+, e.g. @DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.R)")
+            throw UnsupportedApiLevelException(
+                "CSTest subclasses must be annotated to only " +
+                    "run on S+, e.g. @DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.R)"
+            )
         }
     }
 
@@ -146,11 +159,11 @@
         it[ConnectivityService.KEY_DESTROY_FROZEN_SOCKETS_VERSION] = true
         it[ConnectivityService.DELAY_DESTROY_FROZEN_SOCKETS_VERSION] = true
         it[ConnectivityService.ALLOW_SYSUI_CONNECTIVITY_REPORTS] = true
-        it[ConnectivityService.LOG_BPF_RC] = true
         it[ConnectivityService.ALLOW_SATALLITE_NETWORK_FALLBACK] = true
+        it[ConnectivityFlags.INGRESS_TO_VPN_ADDRESS_FILTERING] = true
+        it[ConnectivityFlags.BACKGROUND_FIREWALL_CHAIN] = true
     }
-    fun enableFeature(f: String) = enabledFeatures.set(f, true)
-    fun disableFeature(f: String) = enabledFeatures.set(f, false)
+    fun setFeatureEnabled(flag: String, enabled: Boolean) = enabledFeatures.set(flag, enabled)
 
     // When adding new members, consider if it's not better to build the object in CSTestHelpers
     // to keep this file clean of implementation details. Generally, CSTestHelpers should only
@@ -177,9 +190,11 @@
     val systemConfigManager = makeMockSystemConfigManager()
     val batteryStats = mock<IBatteryStats>()
     val batteryManager = BatteryStatsManager(batteryStats)
+    val appOpsManager = mock<AppOpsManager>()
     val telephonyManager = mock<TelephonyManager>().also {
         doReturn(true).`when`(it).isDataCapable()
     }
+    val subscriptionManager = mock<SubscriptionManager>()
 
     val multicastRoutingCoordinatorService = mock<MulticastRoutingCoordinatorService>()
     val satelliteAccessController = mock<SatelliteAccessController>()
@@ -193,8 +208,32 @@
     lateinit var cm: ConnectivityManager
     lateinit var csHandler: Handler
 
+    // Tests can use this annotation to set flag values before constructing ConnectivityService
+    // e.g. @FeatureFlags([Flag(flagName1, true/false), Flag(flagName2, true/false)])
+    @Retention(RUNTIME)
+    @Target(FUNCTION)
+    annotation class FeatureFlags(val flags: Array<Flag>)
+
+    @Retention(RUNTIME)
+    @Target(FUNCTION)
+    annotation class Flag(val name: String, val enabled: Boolean)
+
     @Before
     fun setUp() {
+        // Set feature flags before constructing ConnectivityService
+        val testMethodName = testNameRule.methodName
+        try {
+            val testMethod = this::class.java.getMethod(testMethodName)
+            val featureFlags = testMethod.getAnnotation(FeatureFlags::class.java)
+            if (featureFlags != null) {
+                for (flag in featureFlags.flags) {
+                    setFeatureEnabled(flag.name, flag.enabled)
+                }
+            }
+        } catch (ignored: NoSuchMethodException) {
+            // This is expected for parameterized tests
+        }
+
         alarmHandlerThread = HandlerThread("TestAlarmManager").also { it.start() }
         alarmManager = makeMockAlarmManager(alarmHandlerThread)
         service = makeConnectivityService(context, netd, deps).also { it.systemReadyInternal() }
@@ -227,7 +266,8 @@
                 context: Context,
                 tm: TelephonyManager,
                 requestRestrictedWifiEnabled: Boolean,
-                listener: BiConsumer<Int, Int>
+                listener: BiConsumer<Int, Int>,
+                handler: Handler
         ) = if (SdkLevel.isAtLeastT()) mock<CarrierPrivilegeAuthenticator>() else null
 
         var satelliteNetworkFallbackUidUpdate: Consumer<Set<Int>>? = null
@@ -249,8 +289,12 @@
                 AutomaticOnOffKeepaliveTracker(c, h, AOOKTDeps(c))
 
         override fun makeMultinetworkPolicyTracker(c: Context, h: Handler, r: Runnable) =
-                MultinetworkPolicyTracker(c, h, r,
-                        MultinetworkPolicyTrackerTestDependencies(connResources.get()))
+                MultinetworkPolicyTracker(
+                        c,
+                        h,
+                        r,
+                        MultinetworkPolicyTrackerTestDependencies(connResources.get())
+                )
 
         override fun makeNetworkRequestStateStatsMetrics(c: Context) =
                 this@CSTest.networkRequestStateStatsMetrics
@@ -264,7 +308,7 @@
                 enabledFeatures[name] ?: fail("Unmocked feature $name, see CSTest.enabledFeatures")
 
         // Mocked change IDs
-        private val enabledChangeIds = ArraySet<Long>()
+        private val enabledChangeIds = arrayListOf(ENABLE_MATCH_LOCAL_NETWORK)
         fun setChangeIdEnabled(enabled: Boolean, changeId: Long) {
             // enabledChangeIds is read on the handler thread and maybe the test thread, so
             // make sure both threads see it before continuing.
@@ -299,6 +343,19 @@
         override fun isAtLeastT() = if (isSdkUnmocked) super.isAtLeastT() else sdkLevel >= VERSION_T
         override fun isAtLeastU() = if (isSdkUnmocked) super.isAtLeastU() else sdkLevel >= VERSION_U
         override fun isAtLeastV() = if (isSdkUnmocked) super.isAtLeastV() else sdkLevel >= VERSION_V
+
+        private var callingUid = CALLING_UID_UNMOCKED
+
+        fun unmockCallingUid() {
+            setCallingUid(CALLING_UID_UNMOCKED)
+        }
+
+        fun setCallingUid(callingUid: Int) {
+            visibleOnHandlerThread(csHandler) { this.callingUid = callingUid }
+        }
+
+        override fun getCallingUid() =
+                if (callingUid == CALLING_UID_UNMOCKED) super.getCallingUid() else callingUid
     }
 
     inner class CSContext(base: Context) : BroadcastInterceptingContext(base) {
@@ -322,8 +379,12 @@
         override fun enforceCallingOrSelfPermission(permission: String, message: String?) {
             // If the permission result does not set in the mMockedPermissions, it will be
             // considered as PERMISSION_GRANTED as existing design to prevent breaking other tests.
-            val granted = checkMockedPermission(permission, Process.myPid(), Process.myUid(),
-                PERMISSION_GRANTED)
+            val granted = checkMockedPermission(
+                permission,
+                Process.myPid(),
+                Process.myUid(),
+                PERMISSION_GRANTED
+            )
             if (!granted.equals(PERMISSION_GRANTED)) {
                 throw SecurityException("[Test] permission denied: " + permission)
             }
@@ -334,8 +395,12 @@
         override fun checkCallingOrSelfPermission(permission: String) =
             checkMockedPermission(permission, Process.myPid(), Process.myUid(), PERMISSION_GRANTED)
 
-        private fun checkMockedPermission(permission: String, pid: Int, uid: Int, default: Int):
-                Int {
+        private fun checkMockedPermission(
+                permission: String,
+                pid: Int,
+                uid: Int,
+                default: Int
+        ): Int {
             val processSpecificKey = "$permission,$pid,$uid"
             return mMockedPermissions[processSpecificKey]
                     ?: mMockedPermissions[permission] ?: default
@@ -397,16 +462,17 @@
             Context.ACTIVITY_SERVICE -> activityManager
             Context.SYSTEM_CONFIG_SERVICE -> systemConfigManager
             Context.TELEPHONY_SERVICE -> telephonyManager
+            Context.TELEPHONY_SUBSCRIPTION_SERVICE -> subscriptionManager
             Context.BATTERY_STATS_SERVICE -> batteryManager
             Context.STATS_MANAGER -> null // Stats manager is final and can't be mocked
+            Context.APP_OPS_SERVICE -> appOpsManager
             else -> super.getSystemService(serviceName)
         }
 
         internal val orderedBroadcastAsUserHistory = ArrayTrackRecord<Intent>().newReadHead()
 
         fun expectNoDataActivityBroadcast(timeoutMs: Int) {
-            assertNull(orderedBroadcastAsUserHistory.poll(
-                    timeoutMs.toLong()) { intent -> true })
+            assertNull(orderedBroadcastAsUserHistory.poll(timeoutMs.toLong()))
         }
 
         override fun sendOrderedBroadcastAsUser(
diff --git a/tests/unit/java/com/android/server/ethernet/EthernetNetworkFactoryTest.java b/tests/unit/java/com/android/server/ethernet/EthernetNetworkFactoryTest.java
index 949e0c2..70d4ad8 100644
--- a/tests/unit/java/com/android/server/ethernet/EthernetNetworkFactoryTest.java
+++ b/tests/unit/java/com/android/server/ethernet/EthernetNetworkFactoryTest.java
@@ -554,4 +554,22 @@
         mNetworkOfferCallback.onNetworkNeeded(createDefaultRequest());
         verify(mIpClient, never()).startProvisioning(any());
     }
+
+    @Test
+    public void testGetMacAddressProvisionedInterface() throws Exception {
+        initEthernetNetworkFactory();
+        createAndVerifyProvisionedInterface(TEST_IFACE);
+
+        final String result = mNetFactory.getHwAddress(TEST_IFACE);
+        assertEquals(HW_ADDR, result);
+    }
+
+    @Test
+    public void testGetMacAddressForNonExistingInterface() {
+        initEthernetNetworkFactory();
+
+        final String result = mNetFactory.getHwAddress(TEST_IFACE);
+        // No interface exists due to not calling createAndVerifyProvisionedInterface(...).
+        assertNull(result);
+    }
 }
diff --git a/tests/unit/java/com/android/server/net/NetworkStatsServiceTest.java b/tests/unit/java/com/android/server/net/NetworkStatsServiceTest.java
index 2be74db..8ceca9a 100644
--- a/tests/unit/java/com/android/server/net/NetworkStatsServiceTest.java
+++ b/tests/unit/java/com/android/server/net/NetworkStatsServiceTest.java
@@ -947,7 +947,16 @@
     }
 
     @Test
-    public void testMobileStatsByRatType() throws Exception {
+    public void testMobileStatsByRatTypeForSatellite() throws Exception {
+        doTestMobileStatsByRatType(new NetworkStateSnapshot[]{buildSatelliteMobileState(IMSI_1)});
+    }
+
+    @Test
+    public void testMobileStatsByRatTypeForCellular() throws Exception {
+        doTestMobileStatsByRatType(new NetworkStateSnapshot[]{buildMobileState(IMSI_1)});
+    }
+
+    private void doTestMobileStatsByRatType(NetworkStateSnapshot[] states) throws Exception {
         final NetworkTemplate template3g = new NetworkTemplate.Builder(MATCH_MOBILE)
                 .setRatType(TelephonyManager.NETWORK_TYPE_UMTS)
                 .setMeteredness(METERED_YES).build();
@@ -957,8 +966,6 @@
         final NetworkTemplate template5g = new NetworkTemplate.Builder(MATCH_MOBILE)
                 .setRatType(TelephonyManager.NETWORK_TYPE_NR)
                 .setMeteredness(METERED_YES).build();
-        final NetworkStateSnapshot[] states =
-                new NetworkStateSnapshot[]{buildMobileState(IMSI_1)};
 
         // 3G network comes online.
         mockNetworkStatsSummary(buildEmptyStats());
@@ -972,7 +979,7 @@
         incrementCurrentTime(MINUTE_IN_MILLIS);
         mockNetworkStatsUidDetail(new NetworkStats(getElapsedRealtime(), 1)
                 .addEntry(new NetworkStats.Entry(TEST_IFACE, UID_RED, SET_DEFAULT, TAG_NONE,
-                        METERED_NO, ROAMING_NO, DEFAULT_NETWORK_NO, 12L, 18L, 14L, 1L, 0L)));
+                         METERED_NO, ROAMING_NO, DEFAULT_NETWORK_NO, 12L, 18L, 14L, 1L, 0L)));
         forcePollAndWaitForIdle();
 
         // Verify 3g templates gets stats.
@@ -987,7 +994,7 @@
         mockNetworkStatsUidDetail(new NetworkStats(getElapsedRealtime(), 1)
                 // Append more traffic on existing 3g stats entry.
                 .addEntry(new NetworkStats.Entry(TEST_IFACE, UID_RED, SET_DEFAULT, TAG_NONE,
-                         METERED_NO, ROAMING_NO, DEFAULT_NETWORK_NO, 16L, 22L, 17L, 2L, 0L))
+                        METERED_NO, ROAMING_NO, DEFAULT_NETWORK_NO, 16L, 22L, 17L, 2L, 0L))
                 // Add entry that is new on 4g.
                 .addEntry(new NetworkStats.Entry(TEST_IFACE, UID_RED, SET_FOREGROUND, TAG_NONE,
                         METERED_NO, ROAMING_NO, DEFAULT_NETWORK_NO, 33L, 27L, 8L, 10L, 1L)));
@@ -1389,6 +1396,57 @@
     }
 
     @Test
+    public void testGetUidStatsForTransportWithCellularAndSatellite() throws Exception {
+        // Setup satellite mobile network and Cellular mobile network
+        mockDefaultSettings();
+        mockNetworkStatsUidDetail(buildEmptyStats());
+
+        final NetworkStateSnapshot mobileState = buildStateOfTransport(
+                NetworkCapabilities.TRANSPORT_CELLULAR, TYPE_MOBILE,
+                TEST_IFACE2, IMSI_1, null /* wifiNetworkKey */,
+                false /* isTemporarilyNotMetered */, false /* isRoaming */);
+
+        final NetworkStateSnapshot[] states = new NetworkStateSnapshot[]{mobileState,
+                buildSatelliteMobileState(IMSI_1)};
+        mService.notifyNetworkStatus(NETWORKS_MOBILE, states, getActiveIface(states),
+                new UnderlyingNetworkInfo[0]);
+        setMobileRatTypeAndWaitForIdle(TelephonyManager.NETWORK_TYPE_LTE);
+
+        // mock traffic on satellite network
+        final NetworkStats.Entry entrySatellite = new NetworkStats.Entry(
+                TEST_IFACE, UID_RED, SET_DEFAULT, TAG_NONE, METERED_NO, ROAMING_NO,
+                DEFAULT_NETWORK_NO, 80L, 5L, 70L, 15L, 1L);
+
+        // mock traffic on cellular network
+        final NetworkStats.Entry entryCellular = new NetworkStats.Entry(
+                TEST_IFACE2, UID_RED, SET_DEFAULT, TAG_NONE, METERED_NO, ROAMING_NO,
+                DEFAULT_NETWORK_NO, 100L, 15L, 150L, 15L, 1L);
+
+        final TetherStatsParcel[] emptyTetherStats = {};
+        // The interfaces that expect to be used to query the stats.
+        final String[] mobileIfaces = {TEST_IFACE, TEST_IFACE2};
+        incrementCurrentTime(HOUR_IN_MILLIS);
+        mockDefaultSettings();
+        mockNetworkStatsUidDetail(new NetworkStats(getElapsedRealtime(), 2)
+                .insertEntry(entrySatellite).insertEntry(entryCellular), emptyTetherStats,
+                mobileIfaces);
+        // with getUidStatsForTransport(TRANSPORT_CELLULAR) return stats of both cellular
+        // and satellite
+        final NetworkStats mobileStats = mService.getUidStatsForTransport(
+                NetworkCapabilities.TRANSPORT_CELLULAR);
+
+        // The iface field of the returned stats should be null because getUidStatsForTransport
+        // clears the interface field before it returns the result.
+        assertValues(mobileStats, null /* iface */, UID_RED, SET_DEFAULT, TAG_NONE,
+                METERED_NO, ROAMING_NO, METERED_NO, 180L, 20L, 220L, 30L, 2L);
+
+        // getUidStatsForTransport(TRANSPORT_SATELLITE) is not supported
+        assertThrows(IllegalArgumentException.class,
+                () -> mService.getUidStatsForTransport(NetworkCapabilities.TRANSPORT_SATELLITE));
+
+    }
+
+    @Test
     public void testForegroundBackground() throws Exception {
         // pretend that network comes online
         mockDefaultSettings();
@@ -2546,6 +2604,12 @@
                 false /* isTemporarilyNotMetered */, false /* isRoaming */);
     }
 
+    private static NetworkStateSnapshot buildSatelliteMobileState(String subscriberId) {
+        return buildStateOfTransport(NetworkCapabilities.TRANSPORT_SATELLITE, TYPE_MOBILE,
+                TEST_IFACE, subscriberId, null /* wifiNetworkKey */,
+                false /* isTemporarilyNotMetered */, false /* isRoaming */);
+    }
+
     private static NetworkStateSnapshot buildTestState(@NonNull String iface,
             @Nullable String wifiNetworkKey) {
         return buildStateOfTransport(NetworkCapabilities.TRANSPORT_TEST, TYPE_TEST,
diff --git a/tests/unit/java/com/android/server/net/TrafficStatsRateLimitCacheTest.kt b/tests/unit/java/com/android/server/net/TrafficStatsRateLimitCacheTest.kt
index 27e6f96..99f762d 100644
--- a/tests/unit/java/com/android/server/net/TrafficStatsRateLimitCacheTest.kt
+++ b/tests/unit/java/com/android/server/net/TrafficStatsRateLimitCacheTest.kt
@@ -16,30 +16,35 @@
 
 package com.android.server.net
 
-import android.net.NetworkStats
+import android.net.NetworkStats.Entry
 import com.android.testutils.DevSdkIgnoreRunner
 import java.time.Clock
+import java.util.function.Supplier
 import kotlin.test.assertEquals
 import kotlin.test.assertNull
+import kotlin.test.fail
 import org.junit.Test
 import org.junit.runner.RunWith
+import org.mockito.Mockito.doReturn
 import org.mockito.Mockito.mock
+import org.mockito.Mockito.verify
 import org.mockito.Mockito.`when`
 
 @RunWith(DevSdkIgnoreRunner::class)
 class TrafficStatsRateLimitCacheTest {
     companion object {
         private const val expiryDurationMs = 1000L
+        private const val maxSize = 2
     }
 
     private val clock = mock(Clock::class.java)
-    private val entry = mock(NetworkStats.Entry::class.java)
-    private val cache = TrafficStatsRateLimitCache(clock, expiryDurationMs)
+    private val entry = mock(Entry::class.java)
+    private val cache = TrafficStatsRateLimitCache(clock, expiryDurationMs, maxSize)
 
     @Test
     fun testGet_returnsEntryIfNotExpired() {
         cache.put("iface", 2, entry)
-        `when`(clock.millis()).thenReturn(500L) // Set clock to before expiry
+        doReturn(500L).`when`(clock).millis() // Set clock to before expiry
         val result = cache.get("iface", 2)
         assertEquals(entry, result)
     }
@@ -47,7 +52,7 @@
     @Test
     fun testGet_returnsNullIfExpired() {
         cache.put("iface", 2, entry)
-        `when`(clock.millis()).thenReturn(2000L) // Set clock to after expiry
+        doReturn(2000L).`when`(clock).millis() // Set clock to after expiry
         assertNull(cache.get("iface", 2))
     }
 
@@ -59,8 +64,8 @@
 
     @Test
     fun testPutAndGet_retrievesCorrectEntryForDifferentKeys() {
-        val entry1 = mock(NetworkStats.Entry::class.java)
-        val entry2 = mock(NetworkStats.Entry::class.java)
+        val entry1 = mock(Entry::class.java)
+        val entry2 = mock(Entry::class.java)
 
         cache.put("iface1", 2, entry1)
         cache.put("iface2", 4, entry2)
@@ -71,8 +76,8 @@
 
     @Test
     fun testPut_overridesExistingEntry() {
-        val entry1 = mock(NetworkStats.Entry::class.java)
-        val entry2 = mock(NetworkStats.Entry::class.java)
+        val entry1 = mock(Entry::class.java)
+        val entry2 = mock(Entry::class.java)
 
         cache.put("iface", 2, entry1)
         cache.put("iface", 2, entry2) // Put with the same key
@@ -81,6 +86,62 @@
     }
 
     @Test
+    fun testPut_removeLru() {
+        // Assumes max size is 2. Verify eldest entry get removed.
+        val entry1 = mock(Entry::class.java)
+        val entry2 = mock(Entry::class.java)
+        val entry3 = mock(Entry::class.java)
+
+        cache.put("iface1", 2, entry1)
+        cache.put("iface2", 4, entry2)
+        cache.put("iface3", 8, entry3)
+
+        assertNull(cache.get("iface1", 2))
+        assertEquals(entry2, cache.get("iface2", 4))
+        assertEquals(entry3, cache.get("iface3", 8))
+    }
+
+    @Test
+    fun testGetOrCompute_cacheHit() {
+        val entry1 = mock(Entry::class.java)
+
+        cache.put("iface1", 2, entry1)
+
+        // Set clock to before expiry.
+        doReturn(500L).`when`(clock).millis()
+
+        // Now call getOrCompute
+        val result = cache.getOrCompute("iface1", 2) {
+            fail("Supplier should not be called")
+        }
+
+        // Assertions
+        assertEquals(entry1, result) // Should get the cached entry.
+    }
+
+    @Suppress("UNCHECKED_CAST")
+    @Test
+    fun testGetOrCompute_cacheMiss() {
+        val entry1 = mock(Entry::class.java)
+
+        cache.put("iface1", 2, entry1)
+
+        // Set clock to after expiry.
+        doReturn(1500L).`when`(clock).millis()
+
+        // Mock the supplier to return our network stats entry.
+        val supplier = mock(Supplier::class.java) as Supplier<Entry>
+        doReturn(entry1).`when`(supplier).get()
+
+        // Now call getOrCompute.
+        val result = cache.getOrCompute("iface1", 2, supplier)
+
+        // Assertions.
+        assertEquals(entry1, result) // Should get the cached entry.
+        verify(supplier).get()
+    }
+
+    @Test
     fun testClear() {
         cache.put("iface", 2, entry)
         cache.clear()
diff --git a/tests/unit/vpn-jarjar-rules.txt b/tests/unit/vpn-jarjar-rules.txt
deleted file mode 100644
index 1a6bddc..0000000
--- a/tests/unit/vpn-jarjar-rules.txt
+++ /dev/null
@@ -1,4 +0,0 @@
-# Only keep classes imported by ConnectivityServiceTest
-keep com.android.server.connectivity.Vpn
-keep com.android.server.connectivity.VpnProfileStore
-keep com.android.server.net.LockdownVpnTracker
diff --git a/thread/apex/ot-daemon.34rc b/thread/apex/ot-daemon.34rc
index 25060d1..86f6b69 100644
--- a/thread/apex/ot-daemon.34rc
+++ b/thread/apex/ot-daemon.34rc
@@ -21,5 +21,5 @@
     user thread_network
     group thread_network inet system
     seclabel u:r:ot_daemon:s0
-    socket ot-daemon/thread-wpan.sock stream 0666 thread_network thread_network
+    socket ot-daemon/thread-wpan.sock stream 0660 thread_network thread_network
     override
diff --git a/thread/demoapp/Android.bp b/thread/demoapp/Android.bp
index fcfd469..117b4f9 100644
--- a/thread/demoapp/Android.bp
+++ b/thread/demoapp/Android.bp
@@ -34,7 +34,19 @@
     libs: [
         "framework-connectivity-t",
     ],
+    required: [
+        "privapp-permissions-com.android.threadnetwork.demoapp",
+    ],
+    system_ext_specific: true,
     certificate: "platform",
     privileged: true,
     platform_apis: true,
 }
+
+prebuilt_etc {
+    name: "privapp-permissions-com.android.threadnetwork.demoapp",
+    src: "privapp-permissions-com.android.threadnetwork.demoapp.xml",
+    sub_dir: "permissions",
+    filename_from_src: true,
+    system_ext_specific: true,
+}
diff --git a/thread/demoapp/privapp-permissions-com.android.threadnetwork.demoapp.xml b/thread/demoapp/privapp-permissions-com.android.threadnetwork.demoapp.xml
new file mode 100644
index 0000000..1995e60
--- /dev/null
+++ b/thread/demoapp/privapp-permissions-com.android.threadnetwork.demoapp.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2024 The Android Open Source Project
+
+     Licensed under the Apache License, Version 2.0 (the "License");
+     you may not use this file except in compliance with the License.
+     You may obtain a copy of the License at
+
+          http://www.apache.org/licenses/LICENSE-2.0
+
+     Unless required by applicable law or agreed to in writing, software
+     distributed under the License is distributed on an "AS IS" BASIS,
+     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+     See the License for the specific language governing permissions and
+     limitations under the License.
+-->
+
+<!-- The privileged permissions needed by the com.android.threadnetwork.demoapp app. -->
+<permissions>
+    <privapp-permissions package="com.android.threadnetwork.demoapp">
+        <permission name="android.permission.THREAD_NETWORK_PRIVILEGED" />
+    </privapp-permissions>
+</permissions>
diff --git a/thread/framework/Android.bp b/thread/framework/Android.bp
index 846253c..f8fe422 100644
--- a/thread/framework/Android.bp
+++ b/thread/framework/Android.bp
@@ -30,3 +30,14 @@
         "//packages/modules/Connectivity:__subpackages__",
     ],
 }
+
+filegroup {
+    name: "framework-thread-ot-daemon-shared-aidl-sources",
+    srcs: [
+        "java/android/net/thread/ChannelMaxPower.aidl",
+    ],
+    path: "java",
+    visibility: [
+        "//external/ot-br-posix:__subpackages__",
+    ],
+}
diff --git a/thread/framework/java/android/net/thread/ActiveOperationalDataset.java b/thread/framework/java/android/net/thread/ActiveOperationalDataset.java
index b74a15a..22457f5 100644
--- a/thread/framework/java/android/net/thread/ActiveOperationalDataset.java
+++ b/thread/framework/java/android/net/thread/ActiveOperationalDataset.java
@@ -18,7 +18,7 @@
 
 import static com.android.internal.util.Preconditions.checkArgument;
 import static com.android.internal.util.Preconditions.checkState;
-import static com.android.net.module.util.HexDump.dumpHexString;
+import static com.android.net.module.util.HexDump.toHexString;
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.Objects.requireNonNull;
@@ -74,42 +74,61 @@
 public final class ActiveOperationalDataset implements Parcelable {
     /** The maximum length of the Active Operational Dataset TLV array in bytes. */
     public static final int LENGTH_MAX_DATASET_TLVS = 254;
+
     /** The length of Extended PAN ID in bytes. */
     public static final int LENGTH_EXTENDED_PAN_ID = 8;
+
     /** The minimum length of Network Name as UTF-8 bytes. */
     public static final int LENGTH_MIN_NETWORK_NAME_BYTES = 1;
+
     /** The maximum length of Network Name as UTF-8 bytes. */
     public static final int LENGTH_MAX_NETWORK_NAME_BYTES = 16;
+
     /** The length of Network Key in bytes. */
     public static final int LENGTH_NETWORK_KEY = 16;
+
     /** The length of Mesh-Local Prefix in bits. */
     public static final int LENGTH_MESH_LOCAL_PREFIX_BITS = 64;
+
     /** The length of PSKc in bytes. */
     public static final int LENGTH_PSKC = 16;
+
     /** The 2.4 GHz channel page. */
     public static final int CHANNEL_PAGE_24_GHZ = 0;
+
     /** The minimum 2.4GHz channel. */
     public static final int CHANNEL_MIN_24_GHZ = 11;
+
     /** The maximum 2.4GHz channel. */
     public static final int CHANNEL_MAX_24_GHZ = 26;
+
     /** @hide */
     @VisibleForTesting public static final int TYPE_CHANNEL = 0;
+
     /** @hide */
     @VisibleForTesting public static final int TYPE_PAN_ID = 1;
+
     /** @hide */
     @VisibleForTesting public static final int TYPE_EXTENDED_PAN_ID = 2;
+
     /** @hide */
     @VisibleForTesting public static final int TYPE_NETWORK_NAME = 3;
+
     /** @hide */
     @VisibleForTesting public static final int TYPE_PSKC = 4;
+
     /** @hide */
     @VisibleForTesting public static final int TYPE_NETWORK_KEY = 5;
+
     /** @hide */
     @VisibleForTesting public static final int TYPE_MESH_LOCAL_PREFIX = 7;
+
     /** @hide */
     @VisibleForTesting public static final int TYPE_SECURITY_POLICY = 12;
+
     /** @hide */
     @VisibleForTesting public static final int TYPE_ACTIVE_TIMESTAMP = 14;
+
     /** @hide */
     @VisibleForTesting public static final int TYPE_CHANNEL_MASK = 53;
 
@@ -591,7 +610,7 @@
         sb.append("{networkName=")
                 .append(getNetworkName())
                 .append(", extendedPanId=")
-                .append(dumpHexString(getExtendedPanId()))
+                .append(toHexString(getExtendedPanId()))
                 .append(", panId=")
                 .append(getPanId())
                 .append(", channel=")
@@ -975,8 +994,10 @@
     public static final class SecurityPolicy {
         /** The default Rotation Time in hours. */
         public static final int DEFAULT_ROTATION_TIME_HOURS = 672;
+
         /** The minimum length of Security Policy flags in bytes. */
         public static final int LENGTH_MIN_SECURITY_POLICY_FLAGS = 1;
+
         /** The length of Rotation Time TLV value in bytes. */
         private static final int LENGTH_SECURITY_POLICY_ROTATION_TIME = 2;
 
@@ -1088,7 +1109,7 @@
             sb.append("{rotation=")
                     .append(mRotationTimeHours)
                     .append(", flags=")
-                    .append(dumpHexString(mFlags))
+                    .append(toHexString(mFlags))
                     .append("}");
             return sb.toString();
         }
diff --git a/thread/framework/java/android/net/thread/ChannelMaxPower.aidl b/thread/framework/java/android/net/thread/ChannelMaxPower.aidl
new file mode 100644
index 0000000..bcda8a8
--- /dev/null
+++ b/thread/framework/java/android/net/thread/ChannelMaxPower.aidl
@@ -0,0 +1,28 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net.thread;
+
+ /**
+  * Mapping from a channel to its max power.
+  *
+  * {@hide}
+  */
+parcelable ChannelMaxPower {
+    int channel; // The Thread radio channel.
+    int maxPower; // The max power in the unit of 0.01dBm. Passing INT16_MAX(32767) will
+                  // disable the channel.
+}
diff --git a/thread/framework/java/android/net/thread/IThreadNetworkController.aidl b/thread/framework/java/android/net/thread/IThreadNetworkController.aidl
index 485e25d..c5ca557 100644
--- a/thread/framework/java/android/net/thread/IThreadNetworkController.aidl
+++ b/thread/framework/java/android/net/thread/IThreadNetworkController.aidl
@@ -17,6 +17,7 @@
 package android.net.thread;
 
 import android.net.thread.ActiveOperationalDataset;
+import android.net.thread.ChannelMaxPower;
 import android.net.thread.IActiveOperationalDatasetReceiver;
 import android.net.thread.IOperationalDatasetCallback;
 import android.net.thread.IOperationReceiver;
@@ -39,6 +40,7 @@
     void leave(in IOperationReceiver receiver);
 
     void setTestNetworkAsUpstream(in String testNetworkInterfaceName, in IOperationReceiver receiver);
+    void setChannelMaxPowers(in ChannelMaxPower[] channelMaxPowers, in IOperationReceiver receiver);
 
     int getThreadVersion();
     void createRandomizedDataset(String networkName, IActiveOperationalDatasetReceiver receiver);
diff --git a/thread/framework/java/android/net/thread/ThreadNetworkController.java b/thread/framework/java/android/net/thread/ThreadNetworkController.java
index db761a3..8d6b40a 100644
--- a/thread/framework/java/android/net/thread/ThreadNetworkController.java
+++ b/thread/framework/java/android/net/thread/ThreadNetworkController.java
@@ -25,10 +25,12 @@
 import android.annotation.NonNull;
 import android.annotation.Nullable;
 import android.annotation.RequiresPermission;
+import android.annotation.Size;
 import android.annotation.SystemApi;
 import android.os.Binder;
 import android.os.OutcomeReceiver;
 import android.os.RemoteException;
+import android.util.SparseIntArray;
 
 import com.android.internal.annotations.GuardedBy;
 import com.android.internal.annotations.VisibleForTesting;
@@ -98,6 +100,12 @@
     /** Thread standard version 1.3. */
     public static final int THREAD_VERSION_1_3 = 4;
 
+    /** Minimum value of max power in unit of 0.01dBm. @hide */
+    private static final int POWER_LIMITATION_MIN = -32768;
+
+    /** Maximum value of max power in unit of 0.01dBm. @hide */
+    private static final int POWER_LIMITATION_MAX = 32767;
+
     /** @hide */
     @Retention(RetentionPolicy.SOURCE)
     @IntDef({THREAD_VERSION_1_3})
@@ -596,6 +604,98 @@
         }
     }
 
+    /**
+     * Sets max power of each channel.
+     *
+     * <p>If not set, the default max power is set by the Thread HAL service or the Thread radio
+     * chip firmware.
+     *
+     * <p>On success, the Pending Dataset is successfully registered and persisted on the Leader and
+     * {@link OutcomeReceiver#onResult} of {@code receiver} will be called; When failed, {@link
+     * OutcomeReceiver#onError} will be called with a specific error:
+     *
+     * <ul>
+     *   <li>{@link ThreadNetworkException#ERROR_UNSUPPORTED_OPERATION} the operation is no
+     *       supported by the platform.
+     * </ul>
+     *
+     * @param channelMaxPowers SparseIntArray (key: channel, value: max power) consists of channel
+     *     and corresponding max power. Valid channel values should be between {@link
+     *     ActiveOperationalDataset#CHANNEL_MIN_24_GHZ} and {@link
+     *     ActiveOperationalDataset#CHANNEL_MAX_24_GHZ}. The unit of the max power is 0.01dBm. Max
+     *     power values should be between INT16_MIN (-32768) and INT16_MAX (32767). If the max power
+     *     is set to INT16_MAX, the corresponding channel is not supported.
+     * @param executor the executor to execute {@code receiver}.
+     * @param receiver the receiver to receive the result of this operation.
+     * @throws IllegalArgumentException if the size of {@code channelMaxPowers} is smaller than 1,
+     *     or invalid channel or max power is configured.
+     * @hide
+     */
+    @RequiresPermission("android.permission.THREAD_NETWORK_PRIVILEGED")
+    public final void setChannelMaxPowers(
+            @NonNull @Size(min = 1) SparseIntArray channelMaxPowers,
+            @NonNull @CallbackExecutor Executor executor,
+            @NonNull OutcomeReceiver<Void, ThreadNetworkException> receiver) {
+        requireNonNull(channelMaxPowers, "channelMaxPowers cannot be null");
+        requireNonNull(executor, "executor cannot be null");
+        requireNonNull(receiver, "receiver cannot be null");
+
+        if (channelMaxPowers.size() < 1) {
+            throw new IllegalArgumentException("channelMaxPowers cannot be empty");
+        }
+
+        for (int i = 0; i < channelMaxPowers.size(); i++) {
+            int channel = channelMaxPowers.keyAt(i);
+            int maxPower = channelMaxPowers.get(channel);
+
+            if ((channel < ActiveOperationalDataset.CHANNEL_MIN_24_GHZ)
+                    || (channel > ActiveOperationalDataset.CHANNEL_MAX_24_GHZ)) {
+                throw new IllegalArgumentException(
+                        "Channel "
+                                + channel
+                                + " exceeds allowed range ["
+                                + ActiveOperationalDataset.CHANNEL_MIN_24_GHZ
+                                + ", "
+                                + ActiveOperationalDataset.CHANNEL_MAX_24_GHZ
+                                + "]");
+            }
+
+            if ((maxPower < POWER_LIMITATION_MIN) || (maxPower > POWER_LIMITATION_MAX)) {
+                throw new IllegalArgumentException(
+                        "Channel power ({channel: "
+                                + channel
+                                + ", maxPower: "
+                                + maxPower
+                                + "}) exceeds allowed range ["
+                                + POWER_LIMITATION_MIN
+                                + ", "
+                                + POWER_LIMITATION_MAX
+                                + "]");
+            }
+        }
+
+        try {
+            mControllerService.setChannelMaxPowers(
+                    toChannelMaxPowerArray(channelMaxPowers),
+                    new OperationReceiverProxy(executor, receiver));
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    private static ChannelMaxPower[] toChannelMaxPowerArray(
+            @NonNull SparseIntArray channelMaxPowers) {
+        final ChannelMaxPower[] powerArray = new ChannelMaxPower[channelMaxPowers.size()];
+
+        for (int i = 0; i < channelMaxPowers.size(); i++) {
+            powerArray[i] = new ChannelMaxPower();
+            powerArray[i].channel = channelMaxPowers.keyAt(i);
+            powerArray[i].maxPower = channelMaxPowers.get(powerArray[i].channel);
+        }
+
+        return powerArray;
+    }
+
     private static <T> void propagateError(
             Executor executor,
             OutcomeReceiver<T, ThreadNetworkException> receiver,
diff --git a/thread/framework/java/android/net/thread/ThreadNetworkException.java b/thread/framework/java/android/net/thread/ThreadNetworkException.java
index 4def0fb..f699c30 100644
--- a/thread/framework/java/android/net/thread/ThreadNetworkException.java
+++ b/thread/framework/java/android/net/thread/ThreadNetworkException.java
@@ -138,8 +138,17 @@
      */
     public static final int ERROR_THREAD_DISABLED = 12;
 
+    /**
+     * The operation failed because it is not supported by the platform. For example, some platforms
+     * may not support setting the target power of each channel. The caller should not retry and may
+     * return an error to the user.
+     *
+     * @hide
+     */
+    public static final int ERROR_UNSUPPORTED_OPERATION = 13;
+
     private static final int ERROR_MIN = ERROR_INTERNAL_ERROR;
-    private static final int ERROR_MAX = ERROR_THREAD_DISABLED;
+    private static final int ERROR_MAX = ERROR_UNSUPPORTED_OPERATION;
 
     private final int mErrorCode;
 
diff --git a/thread/service/Android.bp b/thread/service/Android.bp
index 6e2fac1..a82a499 100644
--- a/thread/service/Android.bp
+++ b/thread/service/Android.bp
@@ -45,6 +45,9 @@
         "modules-utils-shell-command-handler",
         "net-utils-device-common",
         "net-utils-device-common-netlink",
+        // The required dependency net-utils-device-common-struct-base is in the classpath via
+        // framework-connectivity
+        "net-utils-device-common-struct",
         "ot-daemon-aidl-java",
     ],
     apex_available: ["com.android.tethering"],
diff --git a/thread/service/java/com/android/server/thread/ActiveOperationalDatasetReceiverWrapper.java b/thread/service/java/com/android/server/thread/ActiveOperationalDatasetReceiverWrapper.java
index e3b4e1a..43ff336 100644
--- a/thread/service/java/com/android/server/thread/ActiveOperationalDatasetReceiverWrapper.java
+++ b/thread/service/java/com/android/server/thread/ActiveOperationalDatasetReceiverWrapper.java
@@ -16,10 +16,12 @@
 
 package com.android.server.thread;
 
+import static android.net.thread.ThreadNetworkException.ERROR_INTERNAL_ERROR;
 import static android.net.thread.ThreadNetworkException.ERROR_UNAVAILABLE;
 
 import android.net.thread.ActiveOperationalDataset;
 import android.net.thread.IActiveOperationalDatasetReceiver;
+import android.net.thread.ThreadNetworkException;
 import android.os.RemoteException;
 
 import com.android.internal.annotations.GuardedBy;
@@ -73,6 +75,17 @@
         }
     }
 
+    public void onError(Throwable e) {
+        if (e instanceof ThreadNetworkException) {
+            ThreadNetworkException threadException = (ThreadNetworkException) e;
+            onError(threadException.getErrorCode(), threadException.getMessage());
+        } else if (e instanceof RemoteException) {
+            onError(ERROR_INTERNAL_ERROR, "Thread stack error");
+        } else {
+            throw new AssertionError(e);
+        }
+    }
+
     public void onError(int errorCode, String errorMessage) {
         synchronized (sPendingReceiversLock) {
             sPendingReceivers.remove(this);
diff --git a/thread/service/java/com/android/server/thread/NsdPublisher.java b/thread/service/java/com/android/server/thread/NsdPublisher.java
index c74c023..2c14f1d 100644
--- a/thread/service/java/com/android/server/thread/NsdPublisher.java
+++ b/thread/service/java/com/android/server/thread/NsdPublisher.java
@@ -20,6 +20,8 @@
 
 import android.annotation.NonNull;
 import android.content.Context;
+import android.net.InetAddresses;
+import android.net.nsd.DiscoveryRequest;
 import android.net.nsd.NsdManager;
 import android.net.nsd.NsdServiceInfo;
 import android.os.Handler;
@@ -30,14 +32,18 @@
 
 import com.android.internal.annotations.VisibleForTesting;
 import com.android.server.thread.openthread.DnsTxtAttribute;
+import com.android.server.thread.openthread.INsdDiscoverServiceCallback;
 import com.android.server.thread.openthread.INsdPublisher;
+import com.android.server.thread.openthread.INsdResolveServiceCallback;
 import com.android.server.thread.openthread.INsdStatusReceiver;
 
-import java.util.ArrayDeque;
+import java.net.Inet6Address;
+import java.net.InetAddress;
 import java.util.ArrayList;
-import java.util.Deque;
+import java.util.Arrays;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
 import java.util.concurrent.Executor;
 
 /**
@@ -48,14 +54,6 @@
  *
  * <p>All the data members of this class MUST be accessed in the {@code mHandler}'s Thread except
  * {@code mHandler} itself.
- *
- * <p>TODO: b/323300118 - Remove the following mechanism when the race condition in NsdManager is
- * fixed.
- *
- * <p>There's always only one running registration job at any timepoint. All other pending jobs are
- * queued in {@code mRegistrationJobs}. When a registration job is complete (i.e. the according
- * method in {@link NsdManager.RegistrationListener} is called), it will start the next registration
- * job in the queue.
  */
 public final class NsdPublisher extends INsdPublisher.Stub {
     // TODO: b/321883491 - specify network for mDNS operations
@@ -64,7 +62,8 @@
     private final Handler mHandler;
     private final Executor mExecutor;
     private final SparseArray<RegistrationListener> mRegistrationListeners = new SparseArray<>(0);
-    private final Deque<Runnable> mRegistrationJobs = new ArrayDeque<>();
+    private final SparseArray<DiscoveryListener> mDiscoveryListeners = new SparseArray<>(0);
+    private final SparseArray<ServiceInfoListener> mServiceInfoListeners = new SparseArray<>(0);
 
     @VisibleForTesting
     public NsdPublisher(NsdManager nsdManager, Handler handler) {
@@ -87,13 +86,9 @@
             List<DnsTxtAttribute> txt,
             INsdStatusReceiver receiver,
             int listenerId) {
-        postRegistrationJob(
-                () -> {
-                    NsdServiceInfo serviceInfo =
-                            buildServiceInfoForService(
-                                    hostname, name, type, subTypeList, port, txt);
-                    registerInternal(serviceInfo, receiver, listenerId, "service");
-                });
+        NsdServiceInfo serviceInfo =
+                buildServiceInfoForService(hostname, name, type, subTypeList, port, txt);
+        mHandler.post(() -> registerInternal(serviceInfo, receiver, listenerId, "service"));
     }
 
     private static NsdServiceInfo buildServiceInfoForService(
@@ -119,6 +114,27 @@
         return serviceInfo;
     }
 
+    @Override
+    public void registerHost(
+            String name, List<String> addresses, INsdStatusReceiver receiver, int listenerId) {
+        NsdServiceInfo serviceInfo = buildServiceInfoForHost(name, addresses);
+        mHandler.post(() -> registerInternal(serviceInfo, receiver, listenerId, "host"));
+    }
+
+    private static NsdServiceInfo buildServiceInfoForHost(
+            String name, List<String> addressStrings) {
+        NsdServiceInfo serviceInfo = new NsdServiceInfo();
+
+        serviceInfo.setHostname(name);
+        ArrayList<InetAddress> addresses = new ArrayList<>(addressStrings.size());
+        for (String addressString : addressStrings) {
+            addresses.add(InetAddresses.parseNumericAddress(addressString));
+        }
+        serviceInfo.setHostAddresses(addresses);
+
+        return serviceInfo;
+    }
+
     private void registerInternal(
             NsdServiceInfo serviceInfo,
             INsdStatusReceiver receiver,
@@ -144,7 +160,7 @@
     }
 
     public void unregister(INsdStatusReceiver receiver, int listenerId) {
-        postRegistrationJob(() -> unregisterInternal(receiver, listenerId));
+        mHandler.post(() -> unregisterInternal(receiver, listenerId));
     }
 
     public void unregisterInternal(INsdStatusReceiver receiver, int listenerId) {
@@ -171,6 +187,110 @@
         mNsdManager.unregisterService(registrationListener);
     }
 
+    @Override
+    public void discoverService(String type, INsdDiscoverServiceCallback callback, int listenerId) {
+        mHandler.post(() -> discoverServiceInternal(type, callback, listenerId));
+    }
+
+    private void discoverServiceInternal(
+            String type, INsdDiscoverServiceCallback callback, int listenerId) {
+        checkOnHandlerThread();
+        Log.i(
+                TAG,
+                "Discovering services."
+                        + " Listener ID: "
+                        + listenerId
+                        + ", service type: "
+                        + type);
+
+        DiscoveryListener listener = new DiscoveryListener(listenerId, type, callback);
+        mDiscoveryListeners.append(listenerId, listener);
+        DiscoveryRequest discoveryRequest =
+                new DiscoveryRequest.Builder(type).setNetwork(null).build();
+        mNsdManager.discoverServices(discoveryRequest, mExecutor, listener);
+    }
+
+    @Override
+    public void stopServiceDiscovery(int listenerId) {
+        mHandler.post(() -> stopServiceDiscoveryInternal(listenerId));
+    }
+
+    private void stopServiceDiscoveryInternal(int listenerId) {
+        checkOnHandlerThread();
+
+        DiscoveryListener listener = mDiscoveryListeners.get(listenerId);
+        if (listener == null) {
+            Log.w(
+                    TAG,
+                    "Failed to stop service discovery. Listener ID "
+                            + listenerId
+                            + ". The listener is null.");
+            return;
+        }
+
+        Log.i(TAG, "Stopping service discovery. Listener: " + listener);
+        mNsdManager.stopServiceDiscovery(listener);
+    }
+
+    @Override
+    public void resolveService(
+            String name, String type, INsdResolveServiceCallback callback, int listenerId) {
+        mHandler.post(() -> resolveServiceInternal(name, type, callback, listenerId));
+    }
+
+    private void resolveServiceInternal(
+            String name, String type, INsdResolveServiceCallback callback, int listenerId) {
+        checkOnHandlerThread();
+
+        NsdServiceInfo serviceInfo = new NsdServiceInfo();
+        serviceInfo.setServiceName(name);
+        serviceInfo.setServiceType(type);
+        serviceInfo.setNetwork(null);
+        Log.i(
+                TAG,
+                "Resolving service."
+                        + " Listener ID: "
+                        + listenerId
+                        + ", service name: "
+                        + name
+                        + ", service type: "
+                        + type);
+
+        ServiceInfoListener listener = new ServiceInfoListener(serviceInfo, listenerId, callback);
+        mServiceInfoListeners.append(listenerId, listener);
+        mNsdManager.registerServiceInfoCallback(serviceInfo, mExecutor, listener);
+    }
+
+    @Override
+    public void stopServiceResolution(int listenerId) {
+        mHandler.post(() -> stopServiceResolutionInternal(listenerId));
+    }
+
+    private void stopServiceResolutionInternal(int listenerId) {
+        checkOnHandlerThread();
+
+        ServiceInfoListener listener = mServiceInfoListeners.get(listenerId);
+        if (listener == null) {
+            Log.w(
+                    TAG,
+                    "Failed to stop service resolution. Listener ID: "
+                            + listenerId
+                            + ". The listener is null.");
+            return;
+        }
+
+        Log.i(TAG, "Stopping service resolution. Listener: " + listener);
+
+        try {
+            mNsdManager.unregisterServiceInfoCallback(listener);
+        } catch (IllegalArgumentException e) {
+            Log.w(
+                    TAG,
+                    "Failed to stop the service resolution because it's already stopped. Listener: "
+                            + listener);
+        }
+    }
+
     private void checkOnHandlerThread() {
         if (mHandler.getLooper().getThread() != Thread.currentThread()) {
             throw new IllegalStateException(
@@ -178,8 +298,12 @@
         }
     }
 
-    /** On ot-daemon died, unregister all registrations. */
-    public void onOtDaemonDied() {
+    @Override
+    public void reset() {
+        mHandler.post(this::resetInternal);
+    }
+
+    private void resetInternal() {
         checkOnHandlerThread();
         for (int i = 0; i < mRegistrationListeners.size(); ++i) {
             try {
@@ -198,37 +322,9 @@
         mRegistrationListeners.clear();
     }
 
-    // TODO: b/323300118 - Remove this mechanism when the race condition in NsdManager is fixed.
-    /** Fetch the first job from the queue and run it. See the class doc for more details. */
-    private void peekAndRun() {
-        if (mRegistrationJobs.isEmpty()) {
-            return;
-        }
-        Runnable job = mRegistrationJobs.getFirst();
-        job.run();
-    }
-
-    // TODO: b/323300118 - Remove this mechanism when the race condition in NsdManager is fixed.
-    /**
-     * Pop the first job from the queue and run the next job. See the class doc for more details.
-     */
-    private void popAndRunNext() {
-        if (mRegistrationJobs.isEmpty()) {
-            Log.i(TAG, "No registration jobs when trying to pop and run next.");
-            return;
-        }
-        mRegistrationJobs.removeFirst();
-        peekAndRun();
-    }
-
-    private void postRegistrationJob(Runnable registrationJob) {
-        mHandler.post(
-                () -> {
-                    mRegistrationJobs.addLast(registrationJob);
-                    if (mRegistrationJobs.size() == 1) {
-                        peekAndRun();
-                    }
-                });
+    /** On ot-daemon died, reset. */
+    public void onOtDaemonDied() {
+        reset();
     }
 
     private final class RegistrationListener implements NsdManager.RegistrationListener {
@@ -268,7 +364,6 @@
             } catch (RemoteException ignored) {
                 // do nothing if the client is dead
             }
-            popAndRunNext();
         }
 
         @Override
@@ -290,7 +385,6 @@
                     // do nothing if the client is dead
                 }
             }
-            popAndRunNext();
         }
 
         @Override
@@ -308,7 +402,6 @@
             } catch (RemoteException ignored) {
                 // do nothing if the client is dead
             }
-            popAndRunNext();
         }
 
         @Override
@@ -329,7 +422,168 @@
                 }
             }
             mRegistrationListeners.remove(mListenerId);
-            popAndRunNext();
+        }
+    }
+
+    private final class DiscoveryListener implements NsdManager.DiscoveryListener {
+        private final int mListenerId;
+        private final String mType;
+        private final INsdDiscoverServiceCallback mDiscoverServiceCallback;
+
+        DiscoveryListener(
+                int listenerId,
+                @NonNull String type,
+                @NonNull INsdDiscoverServiceCallback discoverServiceCallback) {
+            mListenerId = listenerId;
+            mType = type;
+            mDiscoverServiceCallback = discoverServiceCallback;
+        }
+
+        @Override
+        public void onStartDiscoveryFailed(String serviceType, int errorCode) {
+            Log.e(
+                    TAG,
+                    "Failed to start service discovery."
+                            + " Error code: "
+                            + errorCode
+                            + ", listener: "
+                            + this);
+            mDiscoveryListeners.remove(mListenerId);
+        }
+
+        @Override
+        public void onStopDiscoveryFailed(String serviceType, int errorCode) {
+            Log.e(
+                    TAG,
+                    "Failed to stop service discovery."
+                            + " Error code: "
+                            + errorCode
+                            + ", listener: "
+                            + this);
+            mDiscoveryListeners.remove(mListenerId);
+        }
+
+        @Override
+        public void onDiscoveryStarted(String serviceType) {
+            Log.i(TAG, "Started service discovery. Listener: " + this);
+        }
+
+        @Override
+        public void onDiscoveryStopped(String serviceType) {
+            Log.i(TAG, "Stopped service discovery. Listener: " + this);
+            mDiscoveryListeners.remove(mListenerId);
+        }
+
+        @Override
+        public void onServiceFound(NsdServiceInfo serviceInfo) {
+            Log.i(TAG, "Found service: " + serviceInfo);
+            try {
+                mDiscoverServiceCallback.onServiceDiscovered(
+                        serviceInfo.getServiceName(), mType, true);
+            } catch (RemoteException e) {
+                // do nothing if the client is dead
+            }
+        }
+
+        @Override
+        public void onServiceLost(NsdServiceInfo serviceInfo) {
+            Log.i(TAG, "Lost service: " + serviceInfo);
+            try {
+                mDiscoverServiceCallback.onServiceDiscovered(
+                        serviceInfo.getServiceName(), mType, false);
+            } catch (RemoteException e) {
+                // do nothing if the client is dead
+            }
+        }
+
+        @Override
+        public String toString() {
+            return "ID: " + mListenerId + ", type: " + mType;
+        }
+    }
+
+    private final class ServiceInfoListener implements NsdManager.ServiceInfoCallback {
+        private final String mName;
+        private final String mType;
+        private final INsdResolveServiceCallback mResolveServiceCallback;
+        private final int mListenerId;
+
+        ServiceInfoListener(
+                @NonNull NsdServiceInfo serviceInfo,
+                int listenerId,
+                @NonNull INsdResolveServiceCallback resolveServiceCallback) {
+            mName = serviceInfo.getServiceName();
+            mType = serviceInfo.getServiceType();
+            mListenerId = listenerId;
+            mResolveServiceCallback = resolveServiceCallback;
+        }
+
+        @Override
+        public void onServiceInfoCallbackRegistrationFailed(int errorCode) {
+            Log.e(
+                    TAG,
+                    "Failed to register service info callback."
+                            + " Listener ID: "
+                            + mListenerId
+                            + ", error: "
+                            + errorCode
+                            + ", service name: "
+                            + mName
+                            + ", service type: "
+                            + mType);
+        }
+
+        @Override
+        public void onServiceUpdated(@NonNull NsdServiceInfo serviceInfo) {
+            Log.i(
+                    TAG,
+                    "Service is resolved. "
+                            + " Listener ID: "
+                            + mListenerId
+                            + ", serviceInfo: "
+                            + serviceInfo);
+            List<String> addresses = new ArrayList<>();
+            for (InetAddress address : serviceInfo.getHostAddresses()) {
+                if (address instanceof Inet6Address) {
+                    addresses.add(address.getHostAddress());
+                }
+            }
+            List<DnsTxtAttribute> txtList = new ArrayList<>();
+            for (Map.Entry<String, byte[]> entry : serviceInfo.getAttributes().entrySet()) {
+                DnsTxtAttribute attribute = new DnsTxtAttribute();
+                attribute.name = entry.getKey();
+                attribute.value = Arrays.copyOf(entry.getValue(), entry.getValue().length);
+                txtList.add(attribute);
+            }
+            // TODO: b/329018320 - Use the serviceInfo.getExpirationTime to derive TTL.
+            int ttlSeconds = 10;
+            try {
+                mResolveServiceCallback.onServiceResolved(
+                        serviceInfo.getHostname(),
+                        serviceInfo.getServiceName(),
+                        serviceInfo.getServiceType(),
+                        serviceInfo.getPort(),
+                        addresses,
+                        txtList,
+                        ttlSeconds);
+
+            } catch (RemoteException e) {
+                // do nothing if the client is dead
+            }
+        }
+
+        @Override
+        public void onServiceLost() {}
+
+        @Override
+        public void onServiceInfoCallbackUnregistered() {
+            Log.i(TAG, "The service info callback is unregistered. Listener: " + this);
+            mServiceInfoListeners.remove(mListenerId);
+        }
+
+        @Override
+        public String toString() {
+            return "ID: " + mListenerId + ", service name: " + mName + ", service type: " + mType;
         }
     }
 }
diff --git a/thread/service/java/com/android/server/thread/OperationReceiverWrapper.java b/thread/service/java/com/android/server/thread/OperationReceiverWrapper.java
index a8909bc..bad63f3 100644
--- a/thread/service/java/com/android/server/thread/OperationReceiverWrapper.java
+++ b/thread/service/java/com/android/server/thread/OperationReceiverWrapper.java
@@ -16,9 +16,11 @@
 
 package com.android.server.thread;
 
+import static android.net.thread.ThreadNetworkException.ERROR_INTERNAL_ERROR;
 import static android.net.thread.ThreadNetworkException.ERROR_UNAVAILABLE;
 
 import android.net.thread.IOperationReceiver;
+import android.net.thread.ThreadNetworkException;
 import android.os.RemoteException;
 
 import com.android.internal.annotations.GuardedBy;
@@ -29,6 +31,7 @@
 /** A {@link IOperationReceiver} wrapper which makes it easier to invoke the callbacks. */
 final class OperationReceiverWrapper {
     private final IOperationReceiver mReceiver;
+    private final boolean mExpectOtDaemonDied;
 
     private static final Object sPendingReceiversLock = new Object();
 
@@ -36,7 +39,19 @@
     private static final Set<OperationReceiverWrapper> sPendingReceivers = new HashSet<>();
 
     public OperationReceiverWrapper(IOperationReceiver receiver) {
-        this.mReceiver = receiver;
+        this(receiver, false /* expectOtDaemonDied */);
+    }
+
+    /**
+     * Creates a new {@link OperationReceiverWrapper}.
+     *
+     * <p>If {@code expectOtDaemonDied} is {@code true}, it's expected that ot-daemon becomes dead
+     * before {@code receiver} is completed with {@code onSuccess} and {@code onError} and {@code
+     * receiver#onSuccess} will be invoked in this case.
+     */
+    public OperationReceiverWrapper(IOperationReceiver receiver, boolean expectOtDaemonDied) {
+        mReceiver = receiver;
+        mExpectOtDaemonDied = expectOtDaemonDied;
 
         synchronized (sPendingReceiversLock) {
             sPendingReceivers.add(this);
@@ -47,7 +62,11 @@
         synchronized (sPendingReceiversLock) {
             for (OperationReceiverWrapper receiver : sPendingReceivers) {
                 try {
-                    receiver.mReceiver.onError(ERROR_UNAVAILABLE, "Thread daemon died");
+                    if (receiver.mExpectOtDaemonDied) {
+                        receiver.mReceiver.onSuccess();
+                    } else {
+                        receiver.mReceiver.onError(ERROR_UNAVAILABLE, "Thread daemon died");
+                    }
                 } catch (RemoteException e) {
                     // The client is dead, do nothing
                 }
@@ -68,6 +87,17 @@
         }
     }
 
+    public void onError(Throwable e) {
+        if (e instanceof ThreadNetworkException) {
+            ThreadNetworkException threadException = (ThreadNetworkException) e;
+            onError(threadException.getErrorCode(), threadException.getMessage());
+        } else if (e instanceof RemoteException) {
+            onError(ERROR_INTERNAL_ERROR, "Thread stack error");
+        } else {
+            throw new AssertionError(e);
+        }
+    }
+
     public void onError(int errorCode, String errorMessage, Object... messageArgs) {
         synchronized (sPendingReceiversLock) {
             sPendingReceivers.remove(this);
diff --git a/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java b/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
index 56dd056..63cd574 100644
--- a/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
+++ b/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
@@ -16,7 +16,6 @@
 
 import static android.Manifest.permission.NETWORK_SETTINGS;
 import static android.net.MulticastRoutingConfig.CONFIG_FORWARD_NONE;
-import static android.net.MulticastRoutingConfig.FORWARD_NONE;
 import static android.net.MulticastRoutingConfig.FORWARD_SELECTED;
 import static android.net.MulticastRoutingConfig.FORWARD_WITH_MIN_SCOPE;
 import static android.net.thread.ActiveOperationalDataset.CHANNEL_PAGE_24_GHZ;
@@ -41,6 +40,7 @@
 import static android.net.thread.ThreadNetworkException.ERROR_THREAD_DISABLED;
 import static android.net.thread.ThreadNetworkException.ERROR_TIMEOUT;
 import static android.net.thread.ThreadNetworkException.ERROR_UNSUPPORTED_CHANNEL;
+import static android.net.thread.ThreadNetworkException.ERROR_UNSUPPORTED_OPERATION;
 import static android.net.thread.ThreadNetworkManager.DISALLOW_THREAD_NETWORK;
 import static android.net.thread.ThreadNetworkManager.PERMISSION_THREAD_NETWORK_PRIVILEGED;
 
@@ -48,6 +48,7 @@
 import static com.android.server.thread.openthread.IOtDaemon.ErrorCode.OT_ERROR_BUSY;
 import static com.android.server.thread.openthread.IOtDaemon.ErrorCode.OT_ERROR_FAILED_PRECONDITION;
 import static com.android.server.thread.openthread.IOtDaemon.ErrorCode.OT_ERROR_INVALID_STATE;
+import static com.android.server.thread.openthread.IOtDaemon.ErrorCode.OT_ERROR_NOT_IMPLEMENTED;
 import static com.android.server.thread.openthread.IOtDaemon.ErrorCode.OT_ERROR_NO_BUFS;
 import static com.android.server.thread.openthread.IOtDaemon.ErrorCode.OT_ERROR_PARSE;
 import static com.android.server.thread.openthread.IOtDaemon.ErrorCode.OT_ERROR_REASSEMBLY_TIMEOUT;
@@ -69,7 +70,9 @@
 import android.content.Context;
 import android.content.Intent;
 import android.content.IntentFilter;
+import android.content.res.Resources;
 import android.net.ConnectivityManager;
+import android.net.InetAddresses;
 import android.net.LinkAddress;
 import android.net.LinkProperties;
 import android.net.LocalNetworkConfig;
@@ -85,6 +88,7 @@
 import android.net.TestNetworkSpecifier;
 import android.net.thread.ActiveOperationalDataset;
 import android.net.thread.ActiveOperationalDataset.SecurityPolicy;
+import android.net.thread.ChannelMaxPower;
 import android.net.thread.IActiveOperationalDatasetReceiver;
 import android.net.thread.IOperationReceiver;
 import android.net.thread.IOperationalDatasetCallback;
@@ -94,6 +98,7 @@
 import android.net.thread.PendingOperationalDataset;
 import android.net.thread.ThreadNetworkController;
 import android.net.thread.ThreadNetworkController.DeviceRole;
+import android.net.thread.ThreadNetworkException;
 import android.net.thread.ThreadNetworkException.ErrorCode;
 import android.os.Build;
 import android.os.Handler;
@@ -106,27 +111,36 @@
 import android.util.Log;
 import android.util.SparseArray;
 
+import com.android.connectivity.resources.R;
 import com.android.internal.annotations.VisibleForTesting;
 import com.android.server.ServiceManagerWrapper;
+import com.android.server.connectivity.ConnectivityResources;
+import com.android.server.thread.openthread.BackboneRouterState;
 import com.android.server.thread.openthread.BorderRouterConfigurationParcel;
 import com.android.server.thread.openthread.IChannelMasksReceiver;
 import com.android.server.thread.openthread.IOtDaemon;
 import com.android.server.thread.openthread.IOtDaemonCallback;
 import com.android.server.thread.openthread.IOtStatusReceiver;
 import com.android.server.thread.openthread.Ipv6AddressInfo;
+import com.android.server.thread.openthread.MeshcopTxtAttributes;
 import com.android.server.thread.openthread.OtDaemonState;
 
+import libcore.util.HexEncoding;
+
 import java.io.IOException;
 import java.net.Inet6Address;
 import java.net.InetAddress;
 import java.net.UnknownHostException;
+import java.nio.charset.StandardCharsets;
 import java.security.SecureRandom;
 import java.time.Instant;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Random;
 import java.util.function.Supplier;
+import java.util.regex.Pattern;
 
 /**
  * Implementation of the {@link ThreadNetworkController} API.
@@ -141,6 +155,16 @@
 final class ThreadNetworkControllerService extends IThreadNetworkController.Stub {
     private static final String TAG = "ThreadNetworkService";
 
+    // The model name length in utf-8 bytes
+    private static final int MAX_MODEL_NAME_UTF8_BYTES = 24;
+
+    // The max vendor name length in utf-8 bytes
+    private static final int MAX_VENDOR_NAME_UTF8_BYTES = 24;
+
+    // This regex pattern allows "XXXXXX", "XX:XX:XX" and "XX-XX-XX" OUI formats.
+    // Note that this regex allows "XX:XX-XX" as well but we don't need to be a strict checker
+    private static final String OUI_REGEX = "^([0-9A-Fa-f]{2}[:-]?){2}([0-9A-Fa-f]{2})$";
+
     // Below member fields can be accessed from both the binder and handler threads
 
     private final Context mContext;
@@ -157,7 +181,11 @@
     private final InfraInterfaceController mInfraIfController;
     private final NsdPublisher mNsdPublisher;
     private final OtDaemonCallbackProxy mOtDaemonCallbackProxy = new OtDaemonCallbackProxy();
+    private final ConnectivityResources mResources;
+    private final Supplier<String> mCountryCodeSupplier;
 
+    // This should not be directly used for calling IOtDaemon APIs because ot-daemon may die and
+    // {@code mOtDaemon} will be set to {@code null}. Instead, use {@code getOtDaemon()}
     @Nullable private IOtDaemon mOtDaemon;
     @Nullable private NetworkAgent mNetworkAgent;
     @Nullable private NetworkAgent mTestNetworkAgent;
@@ -172,6 +200,7 @@
     private final ThreadPersistentSettings mPersistentSettings;
     private final UserManager mUserManager;
     private boolean mUserRestricted;
+    private boolean mForceStopOtDaemonEnabled;
 
     private BorderRouterConfigurationParcel mBorderRouterConfig;
 
@@ -186,7 +215,9 @@
             InfraInterfaceController infraIfController,
             ThreadPersistentSettings persistentSettings,
             NsdPublisher nsdPublisher,
-            UserManager userManager) {
+            UserManager userManager,
+            ConnectivityResources resources,
+            Supplier<String> countryCodeSupplier) {
         mContext = context;
         mHandler = handler;
         mNetworkProvider = networkProvider;
@@ -200,10 +231,14 @@
         mPersistentSettings = persistentSettings;
         mNsdPublisher = nsdPublisher;
         mUserManager = userManager;
+        mResources = resources;
+        mCountryCodeSupplier = countryCodeSupplier;
     }
 
     public static ThreadNetworkControllerService newInstance(
-            Context context, ThreadPersistentSettings persistentSettings) {
+            Context context,
+            ThreadPersistentSettings persistentSettings,
+            Supplier<String> countryCodeSupplier) {
         HandlerThread handlerThread = new HandlerThread("ThreadHandlerThread");
         handlerThread.start();
         Handler handler = new Handler(handlerThread.getLooper());
@@ -220,7 +255,9 @@
                 new InfraInterfaceController(),
                 persistentSettings,
                 NsdPublisher.newInstance(context, handler),
-                context.getSystemService(UserManager.class));
+                context.getSystemService(UserManager.class),
+                new ConnectivityResources(context),
+                countryCodeSupplier);
     }
 
     private static Inet6Address bytesToInet6Address(byte[] addressBytes) {
@@ -277,17 +314,31 @@
                 .build();
     }
 
-    private void initializeOtDaemon() {
+    private void maybeInitializeOtDaemon() {
+        if (!isEnabled()) {
+            return;
+        }
+
+        Log.i(TAG, "Starting OT daemon...");
+
         try {
             getOtDaemon();
         } catch (RemoteException e) {
-            Log.e(TAG, "Failed to initialize ot-daemon");
+            Log.e(TAG, "Failed to initialize ot-daemon", e);
+        } catch (ThreadNetworkException e) {
+            // no ThreadNetworkException.ERROR_THREAD_DISABLED error should be thrown
+            throw new AssertionError(e);
         }
     }
 
-    private IOtDaemon getOtDaemon() throws RemoteException {
+    private IOtDaemon getOtDaemon() throws RemoteException, ThreadNetworkException {
         checkOnHandlerThread();
 
+        if (mForceStopOtDaemonEnabled) {
+            throw new ThreadNetworkException(
+                    ERROR_THREAD_DISABLED, "ot-daemon is forcibly stopped");
+        }
+
         if (mOtDaemon != null) {
             return mOtDaemon;
         }
@@ -296,29 +347,75 @@
         if (otDaemon == null) {
             throw new RemoteException("Internal error: failed to start OT daemon");
         }
-        otDaemon.initialize(mTunIfController.getTunFd(), isEnabled(), mNsdPublisher);
-        otDaemon.registerStateCallback(mOtDaemonCallbackProxy, -1);
+
+        otDaemon.initialize(
+                mTunIfController.getTunFd(),
+                isEnabled(),
+                mNsdPublisher,
+                getMeshcopTxtAttributes(mResources.get()),
+                mOtDaemonCallbackProxy,
+                mCountryCodeSupplier.get());
         otDaemon.asBinder().linkToDeath(() -> mHandler.post(this::onOtDaemonDied), 0);
         mOtDaemon = otDaemon;
         return mOtDaemon;
     }
 
+    @VisibleForTesting
+    static MeshcopTxtAttributes getMeshcopTxtAttributes(Resources resources) {
+        final String modelName = resources.getString(R.string.config_thread_model_name);
+        final String vendorName = resources.getString(R.string.config_thread_vendor_name);
+        final String vendorOui = resources.getString(R.string.config_thread_vendor_oui);
+
+        if (!modelName.isEmpty()) {
+            if (modelName.getBytes(StandardCharsets.UTF_8).length > MAX_MODEL_NAME_UTF8_BYTES) {
+                throw new IllegalStateException(
+                        "Model name is longer than "
+                                + MAX_MODEL_NAME_UTF8_BYTES
+                                + "utf-8 bytes: "
+                                + modelName);
+            }
+        }
+
+        if (!vendorName.isEmpty()) {
+            if (vendorName.getBytes(StandardCharsets.UTF_8).length > MAX_VENDOR_NAME_UTF8_BYTES) {
+                throw new IllegalStateException(
+                        "Vendor name is longer than "
+                                + MAX_VENDOR_NAME_UTF8_BYTES
+                                + " utf-8 bytes: "
+                                + vendorName);
+            }
+        }
+
+        if (!vendorOui.isEmpty() && !Pattern.compile(OUI_REGEX).matcher(vendorOui).matches()) {
+            throw new IllegalStateException("Vendor OUI is invalid: " + vendorOui);
+        }
+
+        MeshcopTxtAttributes meshcopTxts = new MeshcopTxtAttributes();
+        meshcopTxts.modelName = modelName;
+        meshcopTxts.vendorName = vendorName;
+        meshcopTxts.vendorOui = HexEncoding.decode(vendorOui.replace("-", "").replace(":", ""));
+        return meshcopTxts;
+    }
+
     private void onOtDaemonDied() {
         checkOnHandlerThread();
-        Log.w(TAG, "OT daemon is dead, clean up and restart it...");
+        Log.w(TAG, "OT daemon is dead, clean up...");
 
         OperationReceiverWrapper.onOtDaemonDied();
         mOtDaemonCallbackProxy.onOtDaemonDied();
         mTunIfController.onOtDaemonDied();
         mNsdPublisher.onOtDaemonDied();
         mOtDaemon = null;
-        initializeOtDaemon();
+        maybeInitializeOtDaemon();
     }
 
     public void initialize() {
         mHandler.post(
                 () -> {
-                    Log.d(TAG, "Initializing Thread system service...");
+                    Log.d(
+                            TAG,
+                            "Initializing Thread system service: Thread is "
+                                    + (isEnabled() ? "enabled" : "disabled"));
                     try {
                         mTunIfController.createTunInterface();
                     } catch (IOException e) {
@@ -330,10 +427,59 @@
                     requestThreadNetwork();
                     mUserRestricted = isThreadUserRestricted();
                     registerUserRestrictionsReceiver();
-                    initializeOtDaemon();
+                    maybeInitializeOtDaemon();
                 });
     }
 
+    /**
+     * Force stops ot-daemon immediately and prevents ot-daemon from being restarted by
+     * system_server again.
+     *
+     * <p>This is for VTS testing only.
+     */
+    @RequiresPermission(PERMISSION_THREAD_NETWORK_PRIVILEGED)
+    void forceStopOtDaemonForTest(boolean enabled, @NonNull IOperationReceiver receiver) {
+        enforceAllPermissionsGranted(PERMISSION_THREAD_NETWORK_PRIVILEGED);
+
+        mHandler.post(
+                () ->
+                        forceStopOtDaemonForTestInternal(
+                                enabled,
+                                new OperationReceiverWrapper(
+                                        receiver, true /* expectOtDaemonDied */)));
+    }
+
+    private void forceStopOtDaemonForTestInternal(
+            boolean enabled, @NonNull OperationReceiverWrapper receiver) {
+        checkOnHandlerThread();
+        if (enabled == mForceStopOtDaemonEnabled) {
+            receiver.onSuccess();
+            return;
+        }
+
+        if (!enabled) {
+            mForceStopOtDaemonEnabled = false;
+            maybeInitializeOtDaemon();
+            receiver.onSuccess();
+            return;
+        }
+
+        try {
+            getOtDaemon().terminate();
+            // Do not invoke the {@code receiver} callback here but wait for ot-daemon to
+            // become dead, so that it's guaranteed that ot-daemon is stopped when {@code
+            // receiver} is completed
+        } catch (RemoteException e) {
+            Log.e(TAG, "otDaemon.terminate failed", e);
+            receiver.onError(ERROR_INTERNAL_ERROR, "Thread stack error");
+        } catch (ThreadNetworkException e) {
+            // No ThreadNetworkException.ERROR_THREAD_DISABLED error will be thrown
+            throw new AssertionError(e);
+        } finally {
+            mForceStopOtDaemonEnabled = true;
+        }
+    }
+
     public void setEnabled(boolean isEnabled, @NonNull IOperationReceiver receiver) {
         enforceAllPermissionsGranted(PERMISSION_THREAD_NETWORK_PRIVILEGED);
 
@@ -354,6 +500,8 @@
             return;
         }
 
+        Log.i(TAG, "Set Thread enabled: " + isEnabled + ", persist: " + persist);
+
         if (persist) {
             // The persistent setting keeps the desired enabled state, thus it's set regardless
             // the otDaemon set enabled state operation succeeded or not, so that it can recover
@@ -363,9 +511,9 @@
 
         try {
             getOtDaemon().setThreadEnabled(isEnabled, newOtStatusReceiver(receiver));
-        } catch (RemoteException e) {
+        } catch (RemoteException | ThreadNetworkException e) {
             Log.e(TAG, "otDaemon.setThreadEnabled failed", e);
-            receiver.onError(ERROR_INTERNAL_ERROR, "Thread stack error");
+            receiver.onError(e);
         }
     }
 
@@ -422,7 +570,9 @@
 
     /** Returns {@code true} if Thread is set enabled. */
     private boolean isEnabled() {
-        return !mUserRestricted && mPersistentSettings.get(ThreadPersistentSettings.THREAD_ENABLED);
+        return !mForceStopOtDaemonEnabled
+                && !mUserRestricted
+                && mPersistentSettings.get(ThreadPersistentSettings.THREAD_ENABLED);
     }
 
     /** Returns {@code true} if Thread has been restricted for the user. */
@@ -487,7 +637,14 @@
         @Override
         public void onAvailable(@NonNull Network network) {
             checkOnHandlerThread();
-            Log.i(TAG, "Thread network available: " + network);
+            Log.i(TAG, "Thread network is available: " + network);
+        }
+
+        @Override
+        public void onLost(@NonNull Network network) {
+            checkOnHandlerThread();
+            Log.i(TAG, "Thread network is lost: " + network);
+            disableBorderRouting();
         }
 
         @Override
@@ -502,7 +659,7 @@
                             + localNetworkInfo
                             + "}");
             if (localNetworkInfo.getUpstreamNetwork() == null) {
-                mUpstreamNetwork = null;
+                disableBorderRouting();
                 return;
             }
             if (!localNetworkInfo.getUpstreamNetwork().equals(mUpstreamNetwork)) {
@@ -521,6 +678,7 @@
                         // requirement.
                         .clearCapabilities()
                         .addTransportType(NetworkCapabilities.TRANSPORT_THREAD)
+                        .addCapability(NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK)
                         .build(),
                 new ThreadNetworkCallback(),
                 mHandler);
@@ -541,6 +699,7 @@
                 new NetworkCapabilities.Builder()
                         .addTransportType(NetworkCapabilities.TRANSPORT_THREAD)
                         .addCapability(NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK)
+                        .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
                         .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VCN_MANAGED)
                         .build();
         final NetworkScore score =
@@ -602,9 +761,9 @@
 
         try {
             getOtDaemon().getChannelMasks(newChannelMasksReceiver(networkName, receiver));
-        } catch (RemoteException e) {
+        } catch (RemoteException | ThreadNetworkException e) {
             Log.e(TAG, "otDaemon.getChannelMasks failed", e);
-            receiver.onError(ERROR_INTERNAL_ERROR, "Thread stack error");
+            receiver.onError(e);
         }
     }
 
@@ -776,6 +935,8 @@
                 return ERROR_ABORTED;
             case OT_ERROR_BUSY:
                 return ERROR_BUSY;
+            case OT_ERROR_NOT_IMPLEMENTED:
+                return ERROR_UNSUPPORTED_OPERATION;
             case OT_ERROR_NO_BUFS:
                 return ERROR_RESOURCE_EXHAUSTED;
             case OT_ERROR_PARSE:
@@ -814,9 +975,9 @@
         try {
             // The otDaemon.join() will leave first if this device is currently attached
             getOtDaemon().join(activeDataset.toThreadTlvs(), newOtStatusReceiver(receiver));
-        } catch (RemoteException e) {
+        } catch (RemoteException | ThreadNetworkException e) {
             Log.e(TAG, "otDaemon.join failed", e);
-            receiver.onError(ERROR_INTERNAL_ERROR, "Thread stack error");
+            receiver.onError(e);
         }
     }
 
@@ -839,9 +1000,9 @@
             getOtDaemon()
                     .scheduleMigration(
                             pendingDataset.toThreadTlvs(), newOtStatusReceiver(receiver));
-        } catch (RemoteException e) {
+        } catch (RemoteException | ThreadNetworkException e) {
             Log.e(TAG, "otDaemon.scheduleMigration failed", e);
-            receiver.onError(ERROR_INTERNAL_ERROR, "Thread stack error");
+            receiver.onError(e);
         }
     }
 
@@ -857,9 +1018,9 @@
 
         try {
             getOtDaemon().leave(newOtStatusReceiver(receiver));
-        } catch (RemoteException e) {
-            // Oneway AIDL API should never throw?
-            receiver.onError(ERROR_INTERNAL_ERROR, "Thread stack error");
+        } catch (RemoteException | ThreadNetworkException e) {
+            Log.e(TAG, "otDaemon.leave failed", e);
+            receiver.onError(e);
         }
     }
 
@@ -881,11 +1042,18 @@
             String countryCode, @NonNull OperationReceiverWrapper receiver) {
         checkOnHandlerThread();
 
+        // Fails early to avoid waking up ot-daemon by the ThreadNetworkCountryCode class
+        if (!isEnabled()) {
+            receiver.onError(
+                    ERROR_THREAD_DISABLED, "Can't set country code when Thread is disabled");
+            return;
+        }
+
         try {
             getOtDaemon().setCountryCode(countryCode, newOtStatusReceiver(receiver));
-        } catch (RemoteException e) {
+        } catch (RemoteException | ThreadNetworkException e) {
             Log.e(TAG, "otDaemon.setCountryCode failed", e);
-            receiver.onError(ERROR_INTERNAL_ERROR, "Thread stack error");
+            receiver.onError(e);
         }
     }
 
@@ -921,6 +1089,30 @@
         }
     }
 
+    @RequiresPermission(PERMISSION_THREAD_NETWORK_PRIVILEGED)
+    public void setChannelMaxPowers(
+            @NonNull ChannelMaxPower[] channelMaxPowers, @NonNull IOperationReceiver receiver) {
+        enforceAllPermissionsGranted(PERMISSION_THREAD_NETWORK_PRIVILEGED);
+
+        mHandler.post(
+                () ->
+                        setChannelMaxPowersInternal(
+                                channelMaxPowers, new OperationReceiverWrapper(receiver)));
+    }
+
+    private void setChannelMaxPowersInternal(
+            @NonNull ChannelMaxPower[] channelMaxPowers,
+            @NonNull OperationReceiverWrapper receiver) {
+        checkOnHandlerThread();
+
+        try {
+            getOtDaemon().setChannelMaxPowers(channelMaxPowers, newOtStatusReceiver(receiver));
+        } catch (RemoteException | ThreadNetworkException e) {
+            Log.e(TAG, "otDaemon.setChannelMaxPowers failed", e);
+            receiver.onError(ERROR_INTERNAL_ERROR, "Thread stack error");
+        }
+    }
+
     private void enableBorderRouting(String infraIfName) {
         if (mBorderRouterConfig.isBorderRoutingEnabled
                 && infraIfName.equals(mBorderRouterConfig.infraInterfaceName)) {
@@ -933,24 +1125,25 @@
                     mInfraIfController.createIcmp6Socket(infraIfName);
             mBorderRouterConfig.isBorderRoutingEnabled = true;
 
-            mOtDaemon.configureBorderRouter(
-                    mBorderRouterConfig,
-                    new IOtStatusReceiver.Stub() {
-                        @Override
-                        public void onSuccess() {
-                            Log.i(TAG, "configure border router successfully");
-                        }
+            getOtDaemon()
+                    .configureBorderRouter(
+                            mBorderRouterConfig, new ConfigureBorderRouterStatusReceiver());
+        } catch (RemoteException | IOException | ThreadNetworkException e) {
+            Log.w(TAG, "Failed to enable border routing", e);
+        }
+    }
 
-                        @Override
-                        public void onError(int i, String s) {
-                            Log.w(
-                                    TAG,
-                                    String.format(
-                                            "failed to configure border router: %d %s", i, s));
-                        }
-                    });
-        } catch (Exception e) {
-            Log.w(TAG, "enableBorderRouting failed: " + e);
+    private void disableBorderRouting() {
+        mUpstreamNetwork = null;
+        mBorderRouterConfig.infraInterfaceName = null;
+        mBorderRouterConfig.infraInterfaceIcmp6Socket = null;
+        mBorderRouterConfig.isBorderRoutingEnabled = false;
+        try {
+            getOtDaemon()
+                    .configureBorderRouter(
+                            mBorderRouterConfig, new ConfigureBorderRouterStatusReceiver());
+        } catch (RemoteException | ThreadNetworkException e) {
+            Log.w(TAG, "Failed to disable border routing", e);
         }
     }
 
@@ -1001,11 +1194,6 @@
         }
     }
 
-    private boolean isMulticastForwardingEnabled() {
-        return !(mUpstreamMulticastRoutingConfig.getForwardingMode() == FORWARD_NONE
-                && mDownstreamMulticastRoutingConfig.getForwardingMode() == FORWARD_NONE);
-    }
-
     private void sendLocalNetworkConfig() {
         if (mNetworkAgent == null) {
             return;
@@ -1015,72 +1203,44 @@
         Log.d(TAG, "Sent localNetworkConfig: " + localNetworkConfig);
     }
 
-    private void handleMulticastForwardingStateChanged(boolean isEnabled) {
-        if (isMulticastForwardingEnabled() == isEnabled) {
-            return;
-        }
+    private void handleMulticastForwardingChanged(BackboneRouterState state) {
+        MulticastRoutingConfig upstreamMulticastRoutingConfig;
+        MulticastRoutingConfig downstreamMulticastRoutingConfig;
 
-        Log.i(TAG, "Multicast forwaring is " + (isEnabled ? "enabled" : "disabled"));
-
-        if (isEnabled) {
+        if (state.multicastForwardingEnabled) {
             // When multicast forwarding is enabled, setup upstream forwarding to any address
             // with minimal scope 4
             // setup downstream forwarding with addresses subscribed from Thread network
-            mUpstreamMulticastRoutingConfig =
+            upstreamMulticastRoutingConfig =
                     new MulticastRoutingConfig.Builder(FORWARD_WITH_MIN_SCOPE, 4).build();
-            mDownstreamMulticastRoutingConfig =
-                    new MulticastRoutingConfig.Builder(FORWARD_SELECTED).build();
+            downstreamMulticastRoutingConfig =
+                    buildDownstreamMulticastRoutingConfigSelected(state.listeningAddresses);
         } else {
             // When multicast forwarding is disabled, set both upstream and downstream
             // forwarding config to FORWARD_NONE.
-            mUpstreamMulticastRoutingConfig = CONFIG_FORWARD_NONE;
-            mDownstreamMulticastRoutingConfig = CONFIG_FORWARD_NONE;
+            upstreamMulticastRoutingConfig = CONFIG_FORWARD_NONE;
+            downstreamMulticastRoutingConfig = CONFIG_FORWARD_NONE;
         }
+
+        if (upstreamMulticastRoutingConfig.equals(mUpstreamMulticastRoutingConfig)
+                && downstreamMulticastRoutingConfig.equals(mDownstreamMulticastRoutingConfig)) {
+            return;
+        }
+
+        mUpstreamMulticastRoutingConfig = upstreamMulticastRoutingConfig;
+        mDownstreamMulticastRoutingConfig = downstreamMulticastRoutingConfig;
         sendLocalNetworkConfig();
     }
 
-    private void handleMulticastForwardingAddressChanged(byte[] addressBytes, boolean isAdded) {
-        Inet6Address address = bytesToInet6Address(addressBytes);
-        MulticastRoutingConfig newDownstreamConfig;
-        MulticastRoutingConfig.Builder builder;
-
-        if (mDownstreamMulticastRoutingConfig.getForwardingMode()
-                != MulticastRoutingConfig.FORWARD_SELECTED) {
-            Log.e(
-                    TAG,
-                    "Ignore multicast listening address updates when downstream multicast "
-                            + "forwarding mode is not FORWARD_SELECTED");
-            // Don't update the address set if downstream multicast forwarding is disabled.
-            return;
-        }
-        if (isAdded
-                == mDownstreamMulticastRoutingConfig.getListeningAddresses().contains(address)) {
-            return;
-        }
-
-        builder = new MulticastRoutingConfig.Builder(FORWARD_SELECTED);
-        for (Inet6Address listeningAddress :
-                mDownstreamMulticastRoutingConfig.getListeningAddresses()) {
-            builder.addListeningAddress(listeningAddress);
-        }
-
-        if (isAdded) {
+    private MulticastRoutingConfig buildDownstreamMulticastRoutingConfigSelected(
+            List<String> listeningAddresses) {
+        MulticastRoutingConfig.Builder builder =
+                new MulticastRoutingConfig.Builder(FORWARD_SELECTED);
+        for (String addressStr : listeningAddresses) {
+            Inet6Address address = (Inet6Address) InetAddresses.parseNumericAddress(addressStr);
             builder.addListeningAddress(address);
-        } else {
-            builder.clearListeningAddress(address);
         }
-
-        newDownstreamConfig = builder.build();
-        if (!newDownstreamConfig.equals(mDownstreamMulticastRoutingConfig)) {
-            Log.d(
-                    TAG,
-                    "Multicast listening address "
-                            + address.getHostAddress()
-                            + " is "
-                            + (isAdded ? "added" : "removed"));
-            mDownstreamMulticastRoutingConfig = newDownstreamConfig;
-            sendLocalNetworkConfig();
-        }
+        return builder.build();
     }
 
     private static final class CallbackMetadata {
@@ -1104,6 +1264,20 @@
         }
     }
 
+    private static final class ConfigureBorderRouterStatusReceiver extends IOtStatusReceiver.Stub {
+        public ConfigureBorderRouterStatusReceiver() {}
+
+        @Override
+        public void onSuccess() {
+            Log.i(TAG, "Configured border router successfully");
+        }
+
+        @Override
+        public void onError(int i, String s) {
+            Log.w(TAG, String.format("Failed to configure border router: %d %s", i, s));
+        }
+    }
+
     /**
      * Handles and forwards Thread daemon callbacks. This class must be accessed from the thread of
      * {@code mHandler}.
@@ -1136,8 +1310,8 @@
 
             try {
                 getOtDaemon().registerStateCallback(this, callbackMetadata.id);
-            } catch (RemoteException e) {
-                // oneway operation should never fail
+            } catch (RemoteException | ThreadNetworkException e) {
+                Log.e(TAG, "otDaemon.registerStateCallback failed", e);
             }
         }
 
@@ -1177,8 +1351,8 @@
 
             try {
                 getOtDaemon().registerStateCallback(this, callbackMetadata.id);
-            } catch (RemoteException e) {
-                // oneway operation should never fail
+            } catch (RemoteException | ThreadNetworkException e) {
+                Log.e(TAG, "otDaemon.registerStateCallback failed", e);
             }
         }
 
@@ -1197,8 +1371,11 @@
                 return;
             }
 
+            final int deviceRole = mState.deviceRole;
+            mState = null;
+
             // If this device is already STOPPED or DETACHED, do nothing
-            if (!ThreadNetworkController.isAttached(mState.deviceRole)) {
+            if (!ThreadNetworkController.isAttached(deviceRole)) {
                 return;
             }
 
@@ -1248,7 +1425,6 @@
             onInterfaceStateChanged(newState.isInterfaceUp);
             onDeviceRoleChanged(newState.deviceRole, listenerId);
             onPartitionIdChanged(newState.partitionId, listenerId);
-            onMulticastForwardingStateChanged(newState.multicastForwardingEnabled);
             mState = newState;
 
             ActiveOperationalDataset newActiveDataset;
@@ -1357,19 +1533,14 @@
             }
         }
 
-        private void onMulticastForwardingStateChanged(boolean isEnabled) {
-            checkOnHandlerThread();
-            handleMulticastForwardingStateChanged(isEnabled);
-        }
-
         @Override
         public void onAddressChanged(Ipv6AddressInfo addressInfo, boolean isAdded) {
             mHandler.post(() -> handleAddressChanged(addressInfo, isAdded));
         }
 
         @Override
-        public void onMulticastForwardingAddressChanged(byte[] address, boolean isAdded) {
-            mHandler.post(() -> handleMulticastForwardingAddressChanged(address, isAdded));
+        public void onBackboneRouterStateChanged(BackboneRouterState state) {
+            mHandler.post(() -> handleMulticastForwardingChanged(state));
         }
     }
 }
diff --git a/thread/service/java/com/android/server/thread/ThreadNetworkCountryCode.java b/thread/service/java/com/android/server/thread/ThreadNetworkCountryCode.java
index ffa7b44..a194114 100644
--- a/thread/service/java/com/android/server/thread/ThreadNetworkCountryCode.java
+++ b/thread/service/java/com/android/server/thread/ThreadNetworkCountryCode.java
@@ -16,6 +16,8 @@
 
 package com.android.server.thread;
 
+import static com.android.server.thread.ThreadPersistentSettings.THREAD_COUNTRY_CODE;
+
 import android.annotation.Nullable;
 import android.annotation.StringDef;
 import android.annotation.TargetApi;
@@ -83,6 +85,7 @@
                 COUNTRY_CODE_SOURCE_TELEPHONY,
                 COUNTRY_CODE_SOURCE_TELEPHONY_LAST,
                 COUNTRY_CODE_SOURCE_WIFI,
+                COUNTRY_CODE_SOURCE_SETTINGS,
             })
     private @interface CountryCodeSource {}
 
@@ -93,6 +96,7 @@
     private static final String COUNTRY_CODE_SOURCE_TELEPHONY = "Telephony";
     private static final String COUNTRY_CODE_SOURCE_TELEPHONY_LAST = "TelephonyLast";
     private static final String COUNTRY_CODE_SOURCE_WIFI = "Wifi";
+    private static final String COUNTRY_CODE_SOURCE_SETTINGS = "Settings";
 
     private static final CountryCodeInfo DEFAULT_COUNTRY_CODE_INFO =
             new CountryCodeInfo(DEFAULT_COUNTRY_CODE, COUNTRY_CODE_SOURCE_DEFAULT);
@@ -107,6 +111,7 @@
     private final SubscriptionManager mSubscriptionManager;
     private final Map<Integer, TelephonyCountryCodeSlotInfo> mTelephonyCountryCodeSlotInfoMap =
             new ArrayMap();
+    private final ThreadPersistentSettings mPersistentSettings;
 
     @Nullable private CountryCodeInfo mCurrentCountryCodeInfo;
     @Nullable private CountryCodeInfo mLocationCountryCodeInfo;
@@ -215,7 +220,8 @@
             Context context,
             TelephonyManager telephonyManager,
             SubscriptionManager subscriptionManager,
-            @Nullable String oemCountryCode) {
+            @Nullable String oemCountryCode,
+            ThreadPersistentSettings persistentSettings) {
         mLocationManager = locationManager;
         mThreadNetworkControllerService = threadNetworkControllerService;
         mGeocoder = geocoder;
@@ -224,14 +230,19 @@
         mContext = context;
         mTelephonyManager = telephonyManager;
         mSubscriptionManager = subscriptionManager;
+        mPersistentSettings = persistentSettings;
 
         if (oemCountryCode != null) {
             mOemCountryCodeInfo = new CountryCodeInfo(oemCountryCode, COUNTRY_CODE_SOURCE_OEM);
         }
+
+        mCurrentCountryCodeInfo = pickCountryCode();
     }
 
     public static ThreadNetworkCountryCode newInstance(
-            Context context, ThreadNetworkControllerService controllerService) {
+            Context context,
+            ThreadNetworkControllerService controllerService,
+            ThreadPersistentSettings persistentSettings) {
         return new ThreadNetworkCountryCode(
                 context.getSystemService(LocationManager.class),
                 controllerService,
@@ -241,7 +252,8 @@
                 context,
                 context.getSystemService(TelephonyManager.class),
                 context.getSystemService(SubscriptionManager.class),
-                ThreadNetworkProperties.country_code().orElse(null));
+                ThreadNetworkProperties.country_code().orElse(null),
+                persistentSettings);
     }
 
     /** Sets up this country code module to listen to location country code changes. */
@@ -485,6 +497,11 @@
             return mLocationCountryCodeInfo;
         }
 
+        String settingsCountryCode = mPersistentSettings.get(THREAD_COUNTRY_CODE);
+        if (settingsCountryCode != null) {
+            return new CountryCodeInfo(settingsCountryCode, COUNTRY_CODE_SOURCE_SETTINGS);
+        }
+
         if (mOemCountryCodeInfo != null) {
             return mOemCountryCodeInfo;
         }
@@ -498,6 +515,8 @@
             public void onSuccess() {
                 synchronized ("ThreadNetworkCountryCode.this") {
                     mCurrentCountryCodeInfo = countryCodeInfo;
+                    mPersistentSettings.put(
+                            THREAD_COUNTRY_CODE.key, countryCodeInfo.getCountryCode());
                 }
             }
 
@@ -536,10 +555,9 @@
                 newOperationReceiver(countryCodeInfo));
     }
 
-    /** Returns the current country code or {@code null} if no country code is set. */
-    @Nullable
+    /** Returns the current country code. */
     public synchronized String getCountryCode() {
-        return (mCurrentCountryCodeInfo != null) ? mCurrentCountryCodeInfo.getCountryCode() : null;
+        return mCurrentCountryCodeInfo.getCountryCode();
     }
 
     /**
diff --git a/thread/service/java/com/android/server/thread/ThreadNetworkService.java b/thread/service/java/com/android/server/thread/ThreadNetworkService.java
index 5cf27f7..30c67ca 100644
--- a/thread/service/java/com/android/server/thread/ThreadNetworkService.java
+++ b/thread/service/java/com/android/server/thread/ThreadNetworkService.java
@@ -18,21 +18,18 @@
 
 import static android.content.pm.PackageManager.PERMISSION_GRANTED;
 
-import static com.android.net.module.util.DeviceConfigUtils.TETHERING_MODULE_NAME;
+import static java.util.Objects.requireNonNull;
 
 import android.annotation.NonNull;
 import android.annotation.Nullable;
-import android.content.ApexEnvironment;
 import android.content.Context;
 import android.net.thread.IThreadNetworkController;
 import android.net.thread.IThreadNetworkManager;
 import android.os.Binder;
 import android.os.ParcelFileDescriptor;
-import android.util.AtomicFile;
 
 import com.android.server.SystemService;
 
-import java.io.File;
 import java.io.FileDescriptor;
 import java.io.PrintWriter;
 import java.util.Collections;
@@ -51,12 +48,7 @@
     /** Creates a new {@link ThreadNetworkService} object. */
     public ThreadNetworkService(Context context) {
         mContext = context;
-        mPersistentSettings =
-                new ThreadPersistentSettings(
-                        new AtomicFile(
-                                new File(
-                                        getOrCreateThreadnetworkDir(),
-                                        ThreadPersistentSettings.FILE_NAME)));
+        mPersistentSettings = ThreadPersistentSettings.newInstance(context);
     }
 
     /**
@@ -68,15 +60,19 @@
         if (phase == SystemService.PHASE_SYSTEM_SERVICES_READY) {
             mPersistentSettings.initialize();
             mControllerService =
-                    ThreadNetworkControllerService.newInstance(mContext, mPersistentSettings);
+                    ThreadNetworkControllerService.newInstance(
+                            mContext, mPersistentSettings, () -> mCountryCode.getCountryCode());
+            mCountryCode =
+                    ThreadNetworkCountryCode.newInstance(
+                            mContext, mControllerService, mPersistentSettings);
             mControllerService.initialize();
         } else if (phase == SystemService.PHASE_BOOT_COMPLETED) {
             // Country code initialization is delayed to the BOOT_COMPLETED phase because it will
             // call into Wi-Fi and Telephony service whose country code module is ready after
             // PHASE_ACTIVITY_MANAGER_READY and PHASE_THIRD_PARTY_APPS_CAN_START
-            mCountryCode = ThreadNetworkCountryCode.newInstance(mContext, mControllerService);
             mCountryCode.initialize();
-            mShellCommand = new ThreadNetworkShellCommand(mCountryCode);
+            mShellCommand =
+                    new ThreadNetworkShellCommand(requireNonNull(mControllerService), mCountryCode);
         }
     }
 
@@ -123,19 +119,4 @@
 
         pw.println();
     }
-
-    /** Get device protected storage dir for the tethering apex. */
-    private static File getOrCreateThreadnetworkDir() {
-        final File threadnetworkDir;
-        final File apexDataDir =
-                ApexEnvironment.getApexEnvironment(TETHERING_MODULE_NAME)
-                        .getDeviceProtectedDataDir();
-        threadnetworkDir = new File(apexDataDir, "thread");
-
-        if (threadnetworkDir.exists() || threadnetworkDir.mkdirs()) {
-            return threadnetworkDir;
-        }
-        throw new IllegalStateException(
-                "Cannot write into thread network data directory: " + threadnetworkDir);
-    }
 }
diff --git a/thread/service/java/com/android/server/thread/ThreadNetworkShellCommand.java b/thread/service/java/com/android/server/thread/ThreadNetworkShellCommand.java
index c17c5a7..c6a1618 100644
--- a/thread/service/java/com/android/server/thread/ThreadNetworkShellCommand.java
+++ b/thread/service/java/com/android/server/thread/ThreadNetworkShellCommand.java
@@ -16,7 +16,10 @@
 
 package com.android.server.thread;
 
+import android.annotation.NonNull;
 import android.annotation.Nullable;
+import android.net.thread.IOperationReceiver;
+import android.net.thread.ThreadNetworkException;
 import android.os.Binder;
 import android.os.Process;
 import android.text.TextUtils;
@@ -25,7 +28,12 @@
 import com.android.modules.utils.BasicShellCommandHandler;
 
 import java.io.PrintWriter;
+import java.time.Duration;
 import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
 
 /**
  * Interprets and executes 'adb shell cmd thread_network [args]'.
@@ -37,16 +45,22 @@
  * corresponding API permissions.
  */
 public class ThreadNetworkShellCommand extends BasicShellCommandHandler {
-    private static final String TAG = "ThreadNetworkShellCommand";
+    private static final Duration SET_ENABLED_TIMEOUT = Duration.ofSeconds(2);
+    private static final Duration FORCE_STOP_TIMEOUT = Duration.ofSeconds(1);
 
     // These don't require root access.
-    private static final List<String> NON_PRIVILEGED_COMMANDS = List.of("help", "get-country-code");
+    private static final List<String> NON_PRIVILEGED_COMMANDS =
+            List.of("help", "get-country-code", "enable", "disable");
 
-    @Nullable private final ThreadNetworkCountryCode mCountryCode;
+    @NonNull private final ThreadNetworkControllerService mControllerService;
+    @NonNull private final ThreadNetworkCountryCode mCountryCode;
     @Nullable private PrintWriter mOutputWriter;
     @Nullable private PrintWriter mErrorWriter;
 
-    ThreadNetworkShellCommand(@Nullable ThreadNetworkCountryCode countryCode) {
+    ThreadNetworkShellCommand(
+            @NonNull ThreadNetworkControllerService controllerService,
+            @NonNull ThreadNetworkCountryCode countryCode) {
+        mControllerService = controllerService;
         mCountryCode = countryCode;
     }
 
@@ -91,14 +105,14 @@
         }
 
         switch (cmd) {
+            case "enable":
+                return setThreadEnabled(true);
+            case "disable":
+                return setThreadEnabled(false);
+            case "force-stop-ot-daemon":
+                return forceStopOtDaemon();
             case "force-country-code":
                 boolean enabled;
-
-                if (mCountryCode == null) {
-                    perr.println("Thread country code operations are not supported");
-                    return -1;
-                }
-
                 try {
                     enabled = getNextArgRequiredTrueOrFalse("enabled", "disabled");
                 } catch (IllegalArgumentException e) {
@@ -124,11 +138,6 @@
                 }
                 return 0;
             case "get-country-code":
-                if (mCountryCode == null) {
-                    perr.println("Thread country code operations are not supported");
-                    return -1;
-                }
-
                 pw.println("Thread country code = " + mCountryCode.getCountryCode());
                 return 0;
             default:
@@ -136,6 +145,64 @@
         }
     }
 
+    private int setThreadEnabled(boolean enabled) {
+        CompletableFuture<Void> setEnabledFuture = new CompletableFuture<>();
+        mControllerService.setEnabled(enabled, newOperationReceiver(setEnabledFuture));
+        return waitForFuture(setEnabledFuture, FORCE_STOP_TIMEOUT, getErrorWriter());
+    }
+
+    private int forceStopOtDaemon() {
+        final PrintWriter errorWriter = getErrorWriter();
+        boolean enabled;
+        try {
+            enabled = getNextArgRequiredTrueOrFalse("enabled", "disabled");
+        } catch (IllegalArgumentException e) {
+            errorWriter.println("Invalid argument: " + e.getMessage());
+            return -1;
+        }
+
+        CompletableFuture<Void> forceStopFuture = new CompletableFuture<>();
+        mControllerService.forceStopOtDaemonForTest(enabled, newOperationReceiver(forceStopFuture));
+        return waitForFuture(forceStopFuture, FORCE_STOP_TIMEOUT, getErrorWriter());
+    }
+
+    private static IOperationReceiver newOperationReceiver(CompletableFuture<Void> future) {
+        return new IOperationReceiver.Stub() {
+            @Override
+            public void onSuccess() {
+                future.complete(null);
+            }
+
+            @Override
+            public void onError(int errorCode, String errorMessage) {
+                future.completeExceptionally(new ThreadNetworkException(errorCode, errorMessage));
+            }
+        };
+    }
+
+    /**
+     * Waits for the future to complete within given timeout.
+     *
+     * <p>Returns 0 if {@code future} completed successfully, or -1 if {@code future} failed to
+     * complete. When failed, error messages are printed to {@code errorWriter}.
+     */
+    private int waitForFuture(
+            CompletableFuture<Void> future, Duration timeout, PrintWriter errorWriter) {
+        try {
+            future.get(timeout.toSeconds(), TimeUnit.SECONDS);
+            return 0;
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            errorWriter.println("Failed: " + e.getMessage());
+        } catch (ExecutionException e) {
+            errorWriter.println("Failed: " + e.getCause().getMessage());
+        } catch (TimeoutException e) {
+            errorWriter.println("Failed: command timeout for " + timeout);
+        }
+
+        return -1;
+    }
+
     private static boolean argTrueOrFalse(String arg, String trueString, String falseString) {
         if (trueString.equals(arg)) {
             return true;
@@ -159,6 +226,10 @@
     }
 
     private void onHelpNonPrivileged(PrintWriter pw) {
+        pw.println("  enable");
+        pw.println("    Enables Thread radio");
+        pw.println("  disable");
+        pw.println("    Disables Thread radio");
         pw.println("  get-country-code");
         pw.println("    Gets country code as a two-letter string");
     }
@@ -166,6 +237,8 @@
     private void onHelpPrivileged(PrintWriter pw) {
         pw.println("  force-country-code enabled <two-letter code> | disabled ");
         pw.println("    Sets country code to <two-letter code> or left for normal value");
+        pw.println("  force-stop-ot-daemon enabled | disabled ");
+        pw.println("    force stop ot-daemon service");
     }
 
     @Override
diff --git a/thread/service/java/com/android/server/thread/ThreadPersistentSettings.java b/thread/service/java/com/android/server/thread/ThreadPersistentSettings.java
index d32f0bf..8aaff60 100644
--- a/thread/service/java/com/android/server/thread/ThreadPersistentSettings.java
+++ b/thread/service/java/com/android/server/thread/ThreadPersistentSettings.java
@@ -16,15 +16,23 @@
 
 package com.android.server.thread;
 
+import static com.android.net.module.util.DeviceConfigUtils.TETHERING_MODULE_NAME;
+
 import android.annotation.Nullable;
+import android.content.ApexEnvironment;
+import android.content.Context;
 import android.os.PersistableBundle;
 import android.util.AtomicFile;
 import android.util.Log;
 
+import com.android.connectivity.resources.R;
 import com.android.internal.annotations.GuardedBy;
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.server.connectivity.ConnectivityResources;
 
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
+import java.io.File;
 import java.io.FileInputStream;
 import java.io.FileNotFoundException;
 import java.io.FileOutputStream;
@@ -38,10 +46,13 @@
  */
 public class ThreadPersistentSettings {
     private static final String TAG = "ThreadPersistentSettings";
+
     /** File name used for storing settings. */
-    public static final String FILE_NAME = "ThreadPersistentSettings.xml";
+    private static final String FILE_NAME = "ThreadPersistentSettings.xml";
+
     /** Current config store data version. This will be incremented for any additions. */
     private static final int CURRENT_SETTINGS_STORE_DATA_VERSION = 1;
+
     /**
      * Stores the version of the data. This can be used to handle migration of data if some
      * non-backward compatible change introduced.
@@ -50,7 +61,10 @@
 
     /******** Thread persistent setting keys ***************/
     /** Stores the Thread feature toggle state, true for enabled and false for disabled. */
-    public static final Key<Boolean> THREAD_ENABLED = new Key<>("Thread_enabled", true);
+    public static final Key<Boolean> THREAD_ENABLED = new Key<>("thread_enabled", true);
+
+    /** Stores the Thread country code, null if no country code is stored. */
+    public static final Key<String> THREAD_COUNTRY_CODE = new Key<>("thread_country_code", null);
 
     /******** Thread persistent setting keys ***************/
 
@@ -62,16 +76,29 @@
     @GuardedBy("mLock")
     private final PersistableBundle mSettings = new PersistableBundle();
 
-    public ThreadPersistentSettings(AtomicFile atomicFile) {
+    private final ConnectivityResources mResources;
+
+    public static ThreadPersistentSettings newInstance(Context context) {
+        return new ThreadPersistentSettings(
+                new AtomicFile(new File(getOrCreateThreadNetworkDir(), FILE_NAME)),
+                new ConnectivityResources(context));
+    }
+
+    @VisibleForTesting
+    ThreadPersistentSettings(AtomicFile atomicFile, ConnectivityResources resources) {
         mAtomicFile = atomicFile;
+        mResources = resources;
     }
 
     /** Initialize the settings by reading from the settings file. */
     public void initialize() {
         readFromStoreFile();
         synchronized (mLock) {
-            if (mSettings.isEmpty()) {
-                put(THREAD_ENABLED.key, THREAD_ENABLED.defaultValue);
+            if (!mSettings.containsKey(THREAD_ENABLED.key)) {
+                Log.i(TAG, "\"thread_enabled\" is missing in settings file, using default value");
+                put(
+                        THREAD_ENABLED.key,
+                        mResources.get().getBoolean(R.bool.config_thread_default_enabled));
             }
         }
     }
@@ -99,7 +126,9 @@
     private <T> T getObject(String key, T defaultValue) {
         Object value;
         synchronized (mLock) {
-            if (defaultValue instanceof Boolean) {
+            if (defaultValue == null) {
+                value = mSettings.getString(key, null);
+            } else if (defaultValue instanceof Boolean) {
                 value = mSettings.getBoolean(key, (Boolean) defaultValue);
             } else if (defaultValue instanceof Integer) {
                 value = mSettings.getInt(key, (Integer) defaultValue);
@@ -189,7 +218,7 @@
                 mSettings.putAll(bundleRead);
             }
         } catch (FileNotFoundException e) {
-            Log.e(TAG, "No store file to read", e);
+            Log.w(TAG, "No store file to read", e);
         } catch (IOException e) {
             Log.e(TAG, "Read from store file failed", e);
         }
@@ -240,4 +269,19 @@
             throw e;
         }
     }
+
+    /** Get device protected storage dir for the tethering apex. */
+    private static File getOrCreateThreadNetworkDir() {
+        final File threadnetworkDir;
+        final File apexDataDir =
+                ApexEnvironment.getApexEnvironment(TETHERING_MODULE_NAME)
+                        .getDeviceProtectedDataDir();
+        threadnetworkDir = new File(apexDataDir, "thread");
+
+        if (threadnetworkDir.exists() || threadnetworkDir.mkdirs()) {
+            return threadnetworkDir;
+        }
+        throw new IllegalStateException(
+                "Cannot write into thread network data directory: " + threadnetworkDir);
+    }
 }
diff --git a/thread/tests/cts/Android.bp b/thread/tests/cts/Android.bp
index 676eb0e..8cdf38d 100644
--- a/thread/tests/cts/Android.bp
+++ b/thread/tests/cts/Android.bp
@@ -21,7 +21,6 @@
 
 android_test {
     name: "CtsThreadNetworkTestCases",
-    defaults: ["cts_defaults"],
     min_sdk_version: "33",
     sdk_version: "test_current",
     manifest: "AndroidManifest.xml",
@@ -42,6 +41,7 @@
         "guava",
         "guava-android-testlib",
         "net-tests-utils",
+        "ThreadNetworkTestUtils",
         "truth",
     ],
     libs: [
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 3bec36b..ba7392c 100644
--- a/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java
+++ b/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java
@@ -17,7 +17,12 @@
 package android.net.thread.cts;
 
 import static android.Manifest.permission.ACCESS_NETWORK_STATE;
-import static android.Manifest.permission.MANAGE_TEST_NETWORKS;
+import static android.net.NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK;
+import static android.net.NetworkCapabilities.NET_CAPABILITY_NOT_METERED;
+import static android.net.NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED;
+import static android.net.NetworkCapabilities.NET_CAPABILITY_NOT_VCN_MANAGED;
+import static android.net.NetworkCapabilities.NET_CAPABILITY_NOT_VPN;
+import static android.net.NetworkCapabilities.NET_CAPABILITY_TRUSTED;
 import static android.net.thread.ThreadNetworkController.DEVICE_ROLE_CHILD;
 import static android.net.thread.ThreadNetworkController.DEVICE_ROLE_LEADER;
 import static android.net.thread.ThreadNetworkController.DEVICE_ROLE_ROUTER;
@@ -33,7 +38,6 @@
 
 import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation;
 
-import static com.android.testutils.TestNetworkTrackerKt.initTestNetwork;
 import static com.android.testutils.TestPermissionUtil.runAsShell;
 
 import static com.google.common.truth.Truth.assertThat;
@@ -42,13 +46,11 @@
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertThrows;
 import static org.junit.Assert.fail;
-import static org.junit.Assume.assumeNotNull;
 
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
 
 import android.content.Context;
 import android.net.ConnectivityManager;
-import android.net.LinkAddress;
 import android.net.Network;
 import android.net.NetworkCapabilities;
 import android.net.NetworkRequest;
@@ -62,7 +64,10 @@
 import android.net.thread.ThreadNetworkController.StateCallback;
 import android.net.thread.ThreadNetworkException;
 import android.net.thread.ThreadNetworkManager;
-import android.os.Build;
+import android.net.thread.utils.TapTestNetworkTracker;
+import android.net.thread.utils.ThreadFeatureCheckerRule;
+import android.net.thread.utils.ThreadFeatureCheckerRule.RequiresThreadFeature;
+import android.os.HandlerThread;
 import android.os.OutcomeReceiver;
 
 import androidx.annotation.NonNull;
@@ -70,17 +75,12 @@
 import androidx.test.filters.LargeTest;
 
 import com.android.net.module.util.ArrayTrackRecord;
-import com.android.testutils.DevSdkIgnoreRule;
-import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo;
-import com.android.testutils.DevSdkIgnoreRunner;
 import com.android.testutils.FunctionalUtils.ThrowingRunnable;
-import com.android.testutils.TestNetworkTracker;
 
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Rule;
 import org.junit.Test;
-import org.junit.runner.RunWith;
 
 import java.nio.charset.StandardCharsets;
 import java.time.Duration;
@@ -101,8 +101,7 @@
 
 /** CTS tests for {@link ThreadNetworkController}. */
 @LargeTest
-@RunWith(DevSdkIgnoreRunner.class)
-@IgnoreUpTo(Build.VERSION_CODES.TIRAMISU) // Thread is available on only U+
+@RequiresThreadFeature
 public class ThreadNetworkControllerTest {
     private static final int JOIN_TIMEOUT_MILLIS = 30 * 1000;
     private static final int LEAVE_TIMEOUT_MILLIS = 2_000;
@@ -110,12 +109,13 @@
     private static final int NETWORK_CALLBACK_TIMEOUT_MILLIS = 10 * 1000;
     private static final int CALLBACK_TIMEOUT_MILLIS = 1_000;
     private static final int ENABLED_TIMEOUT_MILLIS = 2_000;
-    private static final int SERVICE_DISCOVERY_TIMEOUT_MILLIS = 10 * 1000;
+    private static final int SERVICE_DISCOVERY_TIMEOUT_MILLIS = 30_000;
+    private static final int SERVICE_LOST_TIMEOUT_MILLIS = 20_000;
     private static final String MESHCOP_SERVICE_TYPE = "_meshcop._udp";
     private static final String THREAD_NETWORK_PRIVILEGED =
             "android.permission.THREAD_NETWORK_PRIVILEGED";
 
-    @Rule public DevSdkIgnoreRule mIgnoreRule = new DevSdkIgnoreRule();
+    @Rule public final ThreadFeatureCheckerRule mThreadRule = new ThreadFeatureCheckerRule();
 
     private final Context mContext = ApplicationProvider.getApplicationContext();
     private ExecutorService mExecutor;
@@ -123,35 +123,30 @@
     private NsdManager mNsdManager;
 
     private Set<String> mGrantedPermissions;
+    private HandlerThread mHandlerThread;
+    private TapTestNetworkTracker mTestNetworkTracker;
 
     @Before
     public void setUp() throws Exception {
+        mController =
+                mContext.getSystemService(ThreadNetworkManager.class)
+                        .getAllThreadNetworkControllers()
+                        .get(0);
 
         mGrantedPermissions = new HashSet<String>();
         mExecutor = Executors.newSingleThreadExecutor();
-        ThreadNetworkManager manager = mContext.getSystemService(ThreadNetworkManager.class);
-        if (manager != null) {
-            mController = manager.getAllThreadNetworkControllers().get(0);
-        }
-
-        // TODO: we will also need it in tearDown(), it's better to have a Rule to skip
-        // tests if a feature is not available.
-        assumeNotNull(mController);
+        mNsdManager = mContext.getSystemService(NsdManager.class);
+        mHandlerThread = new HandlerThread(this.getClass().getSimpleName());
+        mHandlerThread.start();
 
         setEnabledAndWait(mController, true);
-
-        mNsdManager = mContext.getSystemService(NsdManager.class);
     }
 
     @After
     public void tearDown() throws Exception {
-        if (mController != null) {
-            grantPermissions(THREAD_NETWORK_PRIVILEGED);
-            CompletableFuture<Void> future = new CompletableFuture<>();
-            mController.leave(mExecutor, future::complete);
-            future.get(LEAVE_TIMEOUT_MILLIS, MILLISECONDS);
-        }
         dropAllPermissions();
+        leaveAndWait(mController);
+        tearDownTestNetwork();
     }
 
     @Test
@@ -816,6 +811,20 @@
 
         assertThat(isAttached(mController)).isTrue();
         assertThat(networkFuture.get(NETWORK_CALLBACK_TIMEOUT_MILLIS, MILLISECONDS)).isNotNull();
+        NetworkCapabilities caps =
+                runAsShell(
+                        ACCESS_NETWORK_STATE, () -> cm.getNetworkCapabilities(networkFuture.get()));
+        assertThat(caps).isNotNull();
+        assertThat(caps.hasTransport(NetworkCapabilities.TRANSPORT_THREAD)).isTrue();
+        assertThat(caps.getCapabilities())
+                .asList()
+                .containsAtLeast(
+                        NET_CAPABILITY_LOCAL_NETWORK,
+                        NET_CAPABILITY_NOT_METERED,
+                        NET_CAPABILITY_NOT_RESTRICTED,
+                        NET_CAPABILITY_NOT_VCN_MANAGED,
+                        NET_CAPABILITY_NOT_VPN,
+                        NET_CAPABILITY_TRUSTED);
     }
 
     private void grantPermissions(String... permissions) {
@@ -829,7 +838,7 @@
 
     @Test
     public void meshcopService_threadEnabledButNotJoined_discoveredButNoNetwork() throws Exception {
-        TestNetworkTracker testNetwork = setUpTestNetwork();
+        setUpTestNetwork();
 
         setEnabledAndWait(mController, true);
         leaveAndWait(mController);
@@ -845,13 +854,11 @@
         assertThat(txtMap.get("rv")).isNotNull();
         assertThat(txtMap.get("tv")).isNotNull();
         assertThat(txtMap.get("sb")).isNotNull();
-
-        tearDownTestNetwork(testNetwork);
     }
 
     @Test
     public void meshcopService_joinedNetwork_discoveredHasNetwork() throws Exception {
-        TestNetworkTracker testNetwork = setUpTestNetwork();
+        setUpTestNetwork();
 
         String networkName = "TestNet" + new Random().nextInt(10_000);
         joinRandomizedDatasetAndWait(mController, networkName);
@@ -872,27 +879,28 @@
         assertThat(txtMap.get("tv")).isNotNull();
         assertThat(txtMap.get("sb")).isNotNull();
         assertThat(txtMap.get("id").length).isEqualTo(16);
-
-        tearDownTestNetwork(testNetwork);
     }
 
     @Test
     public void meshcopService_threadDisabled_notDiscovered() throws Exception {
-        TestNetworkTracker testNetwork = setUpTestNetwork();
-
+        setUpTestNetwork();
         CompletableFuture<NsdServiceInfo> serviceLostFuture = new CompletableFuture<>();
         NsdManager.DiscoveryListener listener =
                 discoverForServiceLost(MESHCOP_SERVICE_TYPE, serviceLostFuture);
+
         setEnabledAndWait(mController, false);
 
         try {
-            serviceLostFuture.get(10_000, MILLISECONDS);
+            serviceLostFuture.get(SERVICE_LOST_TIMEOUT_MILLIS, MILLISECONDS);
+        } catch (InterruptedException | ExecutionException | TimeoutException ignored) {
+            // It's fine if the service lost event didn't show up. The service may not ever be
+            // advertised.
         } finally {
             mNsdManager.stopServiceDiscovery(listener);
         }
-        assertThrows(TimeoutException.class, () -> discoverService(MESHCOP_SERVICE_TYPE));
-
-        tearDownTestNetwork(testNetwork);
+        assertThrows(
+                TimeoutException.class,
+                () -> discoverService(MESHCOP_SERVICE_TYPE, SERVICE_LOST_TIMEOUT_MILLIS));
     }
 
     private static void dropAllPermissions() {
@@ -1110,6 +1118,12 @@
 
     // Return the first discovered service instance.
     private NsdServiceInfo discoverService(String serviceType) throws Exception {
+        return discoverService(serviceType, SERVICE_DISCOVERY_TIMEOUT_MILLIS);
+    }
+
+    // Return the first discovered service instance.
+    private NsdServiceInfo discoverService(String serviceType, int timeoutMilliseconds)
+            throws Exception {
         CompletableFuture<NsdServiceInfo> serviceInfoFuture = new CompletableFuture<>();
         NsdManager.DiscoveryListener listener =
                 new DefaultDiscoveryListener() {
@@ -1118,9 +1132,14 @@
                         serviceInfoFuture.complete(serviceInfo);
                     }
                 };
-        mNsdManager.discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD, listener);
+        mNsdManager.discoverServices(
+                serviceType,
+                NsdManager.PROTOCOL_DNS_SD,
+                mTestNetworkTracker.getNetwork(),
+                mExecutor,
+                listener);
         try {
-            serviceInfoFuture.get(SERVICE_DISCOVERY_TIMEOUT_MILLIS, MILLISECONDS);
+            serviceInfoFuture.get(timeoutMilliseconds, MILLISECONDS);
         } finally {
             mNsdManager.stopServiceDiscovery(listener);
         }
@@ -1137,7 +1156,12 @@
                         serviceInfoFuture.complete(serviceInfo);
                     }
                 };
-        mNsdManager.discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD, listener);
+        mNsdManager.discoverServices(
+                serviceType,
+                NsdManager.PROTOCOL_DNS_SD,
+                mTestNetworkTracker.getNetwork(),
+                mExecutor,
+                listener);
         return listener;
     }
 
@@ -1163,14 +1187,17 @@
         }
     }
 
-    TestNetworkTracker setUpTestNetwork() {
-        return runAsShell(
-                MANAGE_TEST_NETWORKS,
-                () -> initTestNetwork(mContext, new LinkAddress("2001:db8:123::/64"), 10_000));
+    private void setUpTestNetwork() {
+        assertThat(mTestNetworkTracker).isNull();
+        mTestNetworkTracker = new TapTestNetworkTracker(mContext, mHandlerThread.getLooper());
     }
 
-    void tearDownTestNetwork(TestNetworkTracker testNetwork) {
-        runAsShell(MANAGE_TEST_NETWORKS, () -> testNetwork.teardown());
+    private void tearDownTestNetwork() throws InterruptedException {
+        if (mTestNetworkTracker != null) {
+            mTestNetworkTracker.tearDown();
+        }
+        mHandlerThread.quitSafely();
+        mHandlerThread.join();
     }
 
     private static class DefaultDiscoveryListener implements NsdManager.DiscoveryListener {
diff --git a/thread/tests/integration/Android.bp b/thread/tests/integration/Android.bp
index 6ba192d..94985b1 100644
--- a/thread/tests/integration/Android.bp
+++ b/thread/tests/integration/Android.bp
@@ -30,7 +30,9 @@
         "net-tests-utils",
         "net-utils-device-common",
         "net-utils-device-common-bpf",
+        "net-utils-device-common-struct-base",
         "testables",
+        "ThreadNetworkTestUtils",
         "truth",
     ],
     libs: [
diff --git a/thread/tests/integration/src/android/net/thread/BorderRoutingTest.java b/thread/tests/integration/src/android/net/thread/BorderRoutingTest.java
index 7554610..9b1c338 100644
--- a/thread/tests/integration/src/android/net/thread/BorderRoutingTest.java
+++ b/thread/tests/integration/src/android/net/thread/BorderRoutingTest.java
@@ -17,14 +17,10 @@
 package android.net.thread;
 
 import static android.Manifest.permission.MANAGE_TEST_NETWORKS;
-import static android.Manifest.permission.NETWORK_SETTINGS;
-import static android.net.thread.ThreadNetworkManager.PERMISSION_THREAD_NETWORK_PRIVILEGED;
 import static android.net.thread.utils.IntegrationTestUtils.JOIN_TIMEOUT;
-import static android.net.thread.utils.IntegrationTestUtils.RESTART_JOIN_TIMEOUT;
 import static android.net.thread.utils.IntegrationTestUtils.isExpectedIcmpv6Packet;
 import static android.net.thread.utils.IntegrationTestUtils.isFromIpv6Source;
 import static android.net.thread.utils.IntegrationTestUtils.isInMulticastGroup;
-import static android.net.thread.utils.IntegrationTestUtils.isSimulatedThreadRadioSupported;
 import static android.net.thread.utils.IntegrationTestUtils.isToIpv6Destination;
 import static android.net.thread.utils.IntegrationTestUtils.newPacketReader;
 import static android.net.thread.utils.IntegrationTestUtils.pollForPacket;
@@ -33,21 +29,17 @@
 
 import static com.android.net.module.util.NetworkStackConstants.ICMPV6_ECHO_REPLY_TYPE;
 import static com.android.net.module.util.NetworkStackConstants.ICMPV6_ECHO_REQUEST_TYPE;
-import static com.android.testutils.DeviceInfoUtils.isKernelVersionAtLeast;
 import static com.android.testutils.TestNetworkTrackerKt.initTestNetwork;
 import static com.android.testutils.TestPermissionUtil.runAsShell;
 
 import static com.google.common.io.BaseEncoding.base16;
-import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertNull;
-import static org.junit.Assume.assumeNotNull;
-import static org.junit.Assume.assumeTrue;
 
-import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static java.util.Objects.requireNonNull;
 
 import android.content.Context;
 import android.net.InetAddresses;
@@ -55,6 +47,12 @@
 import android.net.MacAddress;
 import android.net.thread.utils.FullThreadDevice;
 import android.net.thread.utils.InfraNetworkDevice;
+import android.net.thread.utils.OtDaemonController;
+import android.net.thread.utils.ThreadFeatureCheckerRule;
+import android.net.thread.utils.ThreadFeatureCheckerRule.RequiresIpv6MulticastRouting;
+import android.net.thread.utils.ThreadFeatureCheckerRule.RequiresSimulationThreadDevice;
+import android.net.thread.utils.ThreadFeatureCheckerRule.RequiresThreadFeature;
+import android.net.thread.utils.ThreadNetworkControllerWrapper;
 import android.os.Handler;
 import android.os.HandlerThread;
 
@@ -67,6 +65,7 @@
 
 import org.junit.After;
 import org.junit.Before;
+import org.junit.Rule;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
@@ -74,27 +73,16 @@
 import java.time.Duration;
 import java.util.ArrayList;
 import java.util.List;
-import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.CountDownLatch;
-import java.util.concurrent.TimeUnit;
 import java.util.function.Predicate;
 
 /** Integration test cases for Thread Border Routing feature. */
 @RunWith(AndroidJUnit4.class)
+@RequiresThreadFeature
+@RequiresSimulationThreadDevice
 @LargeTest
 public class BorderRoutingTest {
     private static final String TAG = BorderRoutingTest.class.getSimpleName();
-    private final Context mContext = ApplicationProvider.getApplicationContext();
-    private ThreadNetworkController mController;
-    private HandlerThread mHandlerThread;
-    private Handler mHandler;
-    private TestNetworkTracker mInfraNetworkTracker;
-    private List<FullThreadDevice> mFtds;
-    private TapPacketReader mInfraNetworkReader;
-    private InfraNetworkDevice mInfraDevice;
-
     private static final int NUM_FTD = 2;
-    private static final String KERNEL_VERSION_MULTICAST_ROUTING_SUPPORTED = "5.15.0";
     private static final Inet6Address GROUP_ADDR_SCOPE_5 =
             (Inet6Address) InetAddresses.parseNumericAddress("ff05::1234");
     private static final Inet6Address GROUP_ADDR_SCOPE_4 =
@@ -113,16 +101,24 @@
     private static final ActiveOperationalDataset DEFAULT_DATASET =
             ActiveOperationalDataset.fromThreadTlvs(DEFAULT_DATASET_TLVS);
 
+    @Rule public final ThreadFeatureCheckerRule mThreadRule = new ThreadFeatureCheckerRule();
+
+    private final Context mContext = ApplicationProvider.getApplicationContext();
+    private final ThreadNetworkControllerWrapper mController =
+            ThreadNetworkControllerWrapper.newInstance(mContext);
+    private OtDaemonController mOtCtl;
+    private HandlerThread mHandlerThread;
+    private Handler mHandler;
+    private TestNetworkTracker mInfraNetworkTracker;
+    private List<FullThreadDevice> mFtds;
+    private TapPacketReader mInfraNetworkReader;
+    private InfraNetworkDevice mInfraDevice;
+
     @Before
     public void setUp() throws Exception {
-        assumeTrue(isSimulatedThreadRadioSupported());
-        final ThreadNetworkManager manager = mContext.getSystemService(ThreadNetworkManager.class);
-        if (manager != null) {
-            mController = manager.getAllThreadNetworkControllers().get(0);
-        }
-
-        // Run the tests on only devices where the Thread feature is available
-        assumeNotNull(mController);
+        // TODO: b/323301831 - This is a workaround to avoid unnecessary delay to re-form a network
+        mOtCtl = new OtDaemonController();
+        mOtCtl.factoryReset();
 
         mHandlerThread = new HandlerThread(getClass().getSimpleName());
         mHandlerThread.start();
@@ -130,13 +126,12 @@
         mFtds = new ArrayList<>();
 
         setUpInfraNetwork();
-
-        // BR forms a network.
-        startBrLeader();
+        mController.setEnabledAndWait(true);
+        mController.joinAndWait(DEFAULT_DATASET);
 
         // Creates a infra network device.
         mInfraNetworkReader = newPacketReader(mInfraNetworkTracker.getTestIface(), mHandler);
-        startInfraDevice();
+        startInfraDeviceAndWaitForOnLinkAddr();
 
         // Create Ftds
         for (int i = 0; i < NUM_FTD; ++i) {
@@ -146,20 +141,8 @@
 
     @After
     public void tearDown() throws Exception {
-        if (mController == null) {
-            return;
-        }
-
-        runAsShell(
-                PERMISSION_THREAD_NETWORK_PRIVILEGED,
-                NETWORK_SETTINGS,
-                () -> {
-                    CountDownLatch latch = new CountDownLatch(2);
-                    mController.setTestNetworkAsUpstream(
-                            null, directExecutor(), v -> latch.countDown());
-                    mController.leave(directExecutor(), v -> latch.countDown());
-                    latch.await(10, TimeUnit.SECONDS);
-                });
+        mController.setTestNetworkAsUpstreamAndWait(null);
+        mController.leaveAndWait();
         tearDownInfraNetwork();
 
         mHandlerThread.quitSafely();
@@ -172,7 +155,7 @@
     }
 
     @Test
-    public void unicastRouting_infraDevicePingTheadDeviceOmr_replyReceived() throws Exception {
+    public void unicastRouting_infraDevicePingThreadDeviceOmr_replyReceived() throws Exception {
         /*
          * <pre>
          * Topology:
@@ -182,22 +165,69 @@
          * </pre>
          */
 
-        // Let ftd join the network.
         FullThreadDevice ftd = mFtds.get(0);
         startFtdChild(ftd);
 
-        // Infra device sends an echo request to FTD's OMR.
         mInfraDevice.sendEchoRequest(ftd.getOmrAddress());
 
         // Infra device receives an echo reply sent by FTD.
-        assertNotNull(pollForPacketOnInfraNetwork(ICMPV6_ECHO_REPLY_TYPE, null /* srcAddress */));
+        assertNotNull(pollForPacketOnInfraNetwork(ICMPV6_ECHO_REPLY_TYPE, ftd.getOmrAddress()));
+    }
+
+    @Test
+    public void unicastRouting_afterFactoryResetInfraDevicePingThreadDeviceOmr_replyReceived()
+            throws Exception {
+        /*
+         * <pre>
+         * Topology:
+         *                 infra network                       Thread
+         * infra device -------------------- Border Router -------------- Full Thread device
+         *                                   (Cuttlefish)
+         * </pre>
+         */
+
+        startInfraDeviceAndWaitForOnLinkAddr();
+        FullThreadDevice ftd = mFtds.get(0);
+        startFtdChild(ftd);
+
+        mInfraDevice.sendEchoRequest(ftd.getOmrAddress());
+
+        assertNotNull(pollForPacketOnInfraNetwork(ICMPV6_ECHO_REPLY_TYPE, ftd.getOmrAddress()));
+    }
+
+    @Test
+    public void unicastRouting_afterInfraNetworkSwitchInfraDevicePingThreadDeviceOmr_replyReceived()
+            throws Exception {
+        /*
+         * <pre>
+         * Topology:
+         *                 infra network                       Thread
+         * infra device -------------------- Border Router -------------- Full Thread device
+         *                                   (Cuttlefish)
+         * </pre>
+         */
+
+        FullThreadDevice ftd = mFtds.get(0);
+        startFtdChild(ftd);
+        Inet6Address ftdOmr = ftd.getOmrAddress();
+        // Create a new infra network and let Thread prefer it
+        TestNetworkTracker oldInfraNetworkTracker = mInfraNetworkTracker;
+        try {
+            setUpInfraNetwork();
+            mInfraNetworkReader = newPacketReader(mInfraNetworkTracker.getTestIface(), mHandler);
+            startInfraDeviceAndWaitForOnLinkAddr();
+
+            mInfraDevice.sendEchoRequest(ftd.getOmrAddress());
+
+            assertNotNull(pollForPacketOnInfraNetwork(ICMPV6_ECHO_REPLY_TYPE, ftdOmr));
+        } finally {
+            runAsShell(MANAGE_TEST_NETWORKS, () -> oldInfraNetworkTracker.teardown());
+        }
     }
 
     @Test
     public void unicastRouting_borderRouterSendsUdpToThreadDevice_datagramReceived()
             throws Exception {
-        assumeTrue(isSimulatedThreadRadioSupported());
-
         /*
          * <pre>
          * Topology:
@@ -207,19 +237,10 @@
          * </pre>
          */
 
-        // BR forms a network.
-        CompletableFuture<Void> joinFuture = new CompletableFuture<>();
-        runAsShell(
-                PERMISSION_THREAD_NETWORK_PRIVILEGED,
-                () -> mController.join(DEFAULT_DATASET, directExecutor(), joinFuture::complete));
-        joinFuture.get(RESTART_JOIN_TIMEOUT.toMillis(), MILLISECONDS);
-
-        // Creates a Full Thread Device (FTD) and lets it join the network.
         FullThreadDevice ftd = mFtds.get(0);
         startFtdChild(ftd);
-        Inet6Address ftdOmr = ftd.getOmrAddress();
-        Inet6Address ftdMlEid = ftd.getMlEid();
-        assertNotNull(ftdMlEid);
+        Inet6Address ftdOmr = requireNonNull(ftd.getOmrAddress());
+        Inet6Address ftdMlEid = requireNonNull(ftd.getMlEid());
 
         ftd.udpBind(ftdOmr, 12345);
         sendUdpMessage(ftdOmr, 12345, "aaaaaaaa");
@@ -231,9 +252,9 @@
     }
 
     @Test
+    @RequiresIpv6MulticastRouting
     public void multicastRouting_ftdSubscribedMulticastAddress_infraLinkJoinsMulticastGroup()
             throws Exception {
-        assumeTrue(isKernelVersionAtLeast(KERNEL_VERSION_MULTICAST_ROUTING_SUPPORTED));
         /*
          * <pre>
          * Topology:
@@ -252,10 +273,10 @@
     }
 
     @Test
+    @RequiresIpv6MulticastRouting
     public void
             multicastRouting_ftdSubscribedScope3MulticastAddress_infraLinkNotJoinMulticastGroup()
                     throws Exception {
-        assumeTrue(isKernelVersionAtLeast(KERNEL_VERSION_MULTICAST_ROUTING_SUPPORTED));
         /*
          * <pre>
          * Topology:
@@ -274,9 +295,9 @@
     }
 
     @Test
+    @RequiresIpv6MulticastRouting
     public void multicastRouting_ftdSubscribedMulticastAddress_canPingfromInfraLink()
             throws Exception {
-        assumeTrue(isKernelVersionAtLeast(KERNEL_VERSION_MULTICAST_ROUTING_SUPPORTED));
         /*
          * <pre>
          * Topology:
@@ -296,9 +317,19 @@
     }
 
     @Test
+    @RequiresIpv6MulticastRouting
+    public void multicastRouting_inboundForwarding_afterBrRejoinFtdRepliesSubscribedAddress()
+            throws Exception {
+
+        // TODO (b/327311034): Testing bbr state switch from primary mode to secondary mode and back
+        // to primary mode requires an additional BR in the Thread network. This is not currently
+        // supported, to be implemented when possible.
+    }
+
+    @Test
+    @RequiresIpv6MulticastRouting
     public void multicastRouting_ftdSubscribedScope3MulticastAddress_cannotPingfromInfraLink()
             throws Exception {
-        assumeTrue(isKernelVersionAtLeast(KERNEL_VERSION_MULTICAST_ROUTING_SUPPORTED));
         /*
          * <pre>
          * Topology:
@@ -318,9 +349,9 @@
     }
 
     @Test
+    @RequiresIpv6MulticastRouting
     public void multicastRouting_ftdNotSubscribedMulticastAddress_cannotPingFromInfraDevice()
             throws Exception {
-        assumeTrue(isKernelVersionAtLeast(KERNEL_VERSION_MULTICAST_ROUTING_SUPPORTED));
         /*
          * <pre>
          * Topology:
@@ -339,9 +370,9 @@
     }
 
     @Test
+    @RequiresIpv6MulticastRouting
     public void multicastRouting_multipleFtdsSubscribedDifferentAddresses_canPingFromInfraDevice()
             throws Exception {
-        assumeTrue(isKernelVersionAtLeast(KERNEL_VERSION_MULTICAST_ROUTING_SUPPORTED));
         /*
          * <pre>
          * Topology:
@@ -364,16 +395,20 @@
         subscribeMulticastAddressAndWait(ftd2, GROUP_ADDR_SCOPE_4);
 
         mInfraDevice.sendEchoRequest(GROUP_ADDR_SCOPE_5);
-        mInfraDevice.sendEchoRequest(GROUP_ADDR_SCOPE_4);
 
         assertNotNull(pollForPacketOnInfraNetwork(ICMPV6_ECHO_REPLY_TYPE, ftd1.getOmrAddress()));
+
+        // Verify ping reply from ftd1 and ftd2 separately as the order of replies can't be
+        // predicted.
+        mInfraDevice.sendEchoRequest(GROUP_ADDR_SCOPE_4);
+
         assertNotNull(pollForPacketOnInfraNetwork(ICMPV6_ECHO_REPLY_TYPE, ftd2.getOmrAddress()));
     }
 
     @Test
+    @RequiresIpv6MulticastRouting
     public void multicastRouting_multipleFtdsSubscribedSameAddress_canPingFromInfraDevice()
             throws Exception {
-        assumeTrue(isKernelVersionAtLeast(KERNEL_VERSION_MULTICAST_ROUTING_SUPPORTED));
         /*
          * <pre>
          * Topology:
@@ -395,17 +430,19 @@
         startFtdChild(ftd2);
         subscribeMulticastAddressAndWait(ftd2, GROUP_ADDR_SCOPE_5);
 
-        // Send the request twice as the order of replies from ftd1 and ftd2 are not guaranteed
-        mInfraDevice.sendEchoRequest(GROUP_ADDR_SCOPE_5);
         mInfraDevice.sendEchoRequest(GROUP_ADDR_SCOPE_5);
 
         assertNotNull(pollForPacketOnInfraNetwork(ICMPV6_ECHO_REPLY_TYPE, ftd1.getOmrAddress()));
+
+        // Send the request twice as the order of replies from ftd1 and ftd2 are not guaranteed
+        mInfraDevice.sendEchoRequest(GROUP_ADDR_SCOPE_5);
+
         assertNotNull(pollForPacketOnInfraNetwork(ICMPV6_ECHO_REPLY_TYPE, ftd2.getOmrAddress()));
     }
 
     @Test
+    @RequiresIpv6MulticastRouting
     public void multicastRouting_outboundForwarding_scopeLargerThan3IsForwarded() throws Exception {
-        assumeTrue(isKernelVersionAtLeast(KERNEL_VERSION_MULTICAST_ROUTING_SUPPORTED));
         /*
          * <pre>
          * Topology:
@@ -429,9 +466,9 @@
     }
 
     @Test
+    @RequiresIpv6MulticastRouting
     public void multicastRouting_outboundForwarding_scopeSmallerThan4IsNotForwarded()
             throws Exception {
-        assumeTrue(isKernelVersionAtLeast(KERNEL_VERSION_MULTICAST_ROUTING_SUPPORTED));
         /*
          * <pre>
          * Topology:
@@ -452,8 +489,8 @@
     }
 
     @Test
+    @RequiresIpv6MulticastRouting
     public void multicastRouting_outboundForwarding_llaToScope4IsNotForwarded() throws Exception {
-        assumeTrue(isKernelVersionAtLeast(KERNEL_VERSION_MULTICAST_ROUTING_SUPPORTED));
         /*
          * <pre>
          * Topology:
@@ -468,15 +505,15 @@
         Inet6Address ftdLla = ftd.getLinkLocalAddress();
         assertNotNull(ftdLla);
 
-        ftd.ping(GROUP_ADDR_SCOPE_4, ftdLla, 100 /* size */, 1 /* count */);
+        ftd.ping(GROUP_ADDR_SCOPE_4, ftdLla);
 
         assertNull(
                 pollForPacketOnInfraNetwork(ICMPV6_ECHO_REQUEST_TYPE, ftdLla, GROUP_ADDR_SCOPE_4));
     }
 
     @Test
+    @RequiresIpv6MulticastRouting
     public void multicastRouting_outboundForwarding_mlaToScope4IsNotForwarded() throws Exception {
-        assumeTrue(isKernelVersionAtLeast(KERNEL_VERSION_MULTICAST_ROUTING_SUPPORTED));
         /*
          * <pre>
          * Topology:
@@ -492,7 +529,7 @@
         assertFalse(ftdMlas.isEmpty());
 
         for (Inet6Address ftdMla : ftdMlas) {
-            ftd.ping(GROUP_ADDR_SCOPE_4, ftdMla, 100 /* size */, 1 /* count */);
+            ftd.ping(GROUP_ADDR_SCOPE_4, ftdMla);
 
             assertNull(
                     pollForPacketOnInfraNetwork(
@@ -501,9 +538,9 @@
     }
 
     @Test
+    @RequiresIpv6MulticastRouting
     public void multicastRouting_infraNetworkSwitch_ftdRepliesToSubscribedAddress()
             throws Exception {
-        assumeTrue(isKernelVersionAtLeast(KERNEL_VERSION_MULTICAST_ROUTING_SUPPORTED));
         /*
          * <pre>
          * Topology:
@@ -522,7 +559,7 @@
         tearDownInfraNetwork();
         setUpInfraNetwork();
         mInfraNetworkReader = newPacketReader(mInfraNetworkTracker.getTestIface(), mHandler);
-        startInfraDevice();
+        startInfraDeviceAndWaitForOnLinkAddr();
 
         mInfraDevice.sendEchoRequest(GROUP_ADDR_SCOPE_5);
 
@@ -530,8 +567,8 @@
     }
 
     @Test
+    @RequiresIpv6MulticastRouting
     public void multicastRouting_infraNetworkSwitch_outboundPacketIsForwarded() throws Exception {
-        assumeTrue(isKernelVersionAtLeast(KERNEL_VERSION_MULTICAST_ROUTING_SUPPORTED));
         /*
          * <pre>
          * Topology:
@@ -549,49 +586,29 @@
         tearDownInfraNetwork();
         setUpInfraNetwork();
         mInfraNetworkReader = newPacketReader(mInfraNetworkTracker.getTestIface(), mHandler);
-        startInfraDevice();
+        startInfraDeviceAndWaitForOnLinkAddr();
 
-        ftd.ping(GROUP_ADDR_SCOPE_5);
         ftd.ping(GROUP_ADDR_SCOPE_4);
 
         assertNotNull(
-                pollForPacketOnInfraNetwork(ICMPV6_ECHO_REQUEST_TYPE, ftdOmr, GROUP_ADDR_SCOPE_5));
-        assertNotNull(
                 pollForPacketOnInfraNetwork(ICMPV6_ECHO_REQUEST_TYPE, ftdOmr, GROUP_ADDR_SCOPE_4));
     }
 
-    private void setUpInfraNetwork() {
+    private void setUpInfraNetwork() throws Exception {
         mInfraNetworkTracker =
                 runAsShell(
                         MANAGE_TEST_NETWORKS,
                         () ->
                                 initTestNetwork(
                                         mContext, new LinkProperties(), 5000 /* timeoutMs */));
-        runAsShell(
-                PERMISSION_THREAD_NETWORK_PRIVILEGED,
-                NETWORK_SETTINGS,
-                () -> {
-                    CompletableFuture<Void> future = new CompletableFuture<>();
-                    mController.setTestNetworkAsUpstream(
-                            mInfraNetworkTracker.getTestIface().getInterfaceName(),
-                            directExecutor(),
-                            future::complete);
-                    future.get(5, TimeUnit.SECONDS);
-                });
+        mController.setTestNetworkAsUpstreamAndWait(
+                mInfraNetworkTracker.getTestIface().getInterfaceName());
     }
 
     private void tearDownInfraNetwork() {
         runAsShell(MANAGE_TEST_NETWORKS, () -> mInfraNetworkTracker.teardown());
     }
 
-    private void startBrLeader() throws Exception {
-        CompletableFuture<Void> joinFuture = new CompletableFuture<>();
-        runAsShell(
-                PERMISSION_THREAD_NETWORK_PRIVILEGED,
-                () -> mController.join(DEFAULT_DATASET, directExecutor(), joinFuture::complete));
-        joinFuture.get(RESTART_JOIN_TIMEOUT.toSeconds(), TimeUnit.SECONDS);
-    }
-
     private void startFtdChild(FullThreadDevice ftd) throws Exception {
         ftd.factoryReset();
         ftd.joinNetwork(DEFAULT_DATASET);
@@ -601,7 +618,7 @@
         assertNotNull(ftdOmr);
     }
 
-    private void startInfraDevice() throws Exception {
+    private void startInfraDeviceAndWaitForOnLinkAddr() throws Exception {
         mInfraDevice =
                 new InfraNetworkDevice(MacAddress.fromString("1:2:3:4:5:6"), mInfraNetworkReader);
         mInfraDevice.runSlaac(Duration.ofSeconds(60));
diff --git a/thread/tests/integration/src/android/net/thread/ServiceDiscoveryTest.java b/thread/tests/integration/src/android/net/thread/ServiceDiscoveryTest.java
new file mode 100644
index 0000000..5a8d21f
--- /dev/null
+++ b/thread/tests/integration/src/android/net/thread/ServiceDiscoveryTest.java
@@ -0,0 +1,501 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net.thread;
+
+import static android.net.InetAddresses.parseNumericAddress;
+import static android.net.nsd.NsdManager.PROTOCOL_DNS_SD;
+import static android.net.thread.utils.IntegrationTestUtils.JOIN_TIMEOUT;
+import static android.net.thread.utils.IntegrationTestUtils.SERVICE_DISCOVERY_TIMEOUT;
+import static android.net.thread.utils.IntegrationTestUtils.discoverForServiceLost;
+import static android.net.thread.utils.IntegrationTestUtils.discoverService;
+import static android.net.thread.utils.IntegrationTestUtils.resolveService;
+import static android.net.thread.utils.IntegrationTestUtils.resolveServiceUntil;
+import static android.net.thread.utils.IntegrationTestUtils.waitFor;
+
+import static com.google.common.io.BaseEncoding.base16;
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertThrows;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+
+import android.content.Context;
+import android.net.nsd.NsdManager;
+import android.net.nsd.NsdServiceInfo;
+import android.net.thread.utils.FullThreadDevice;
+import android.net.thread.utils.OtDaemonController;
+import android.net.thread.utils.TapTestNetworkTracker;
+import android.net.thread.utils.ThreadFeatureCheckerRule;
+import android.net.thread.utils.ThreadFeatureCheckerRule.RequiresSimulationThreadDevice;
+import android.net.thread.utils.ThreadFeatureCheckerRule.RequiresThreadFeature;
+import android.net.thread.utils.ThreadNetworkControllerWrapper;
+import android.os.HandlerThread;
+
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.filters.LargeTest;
+import androidx.test.runner.AndroidJUnit4;
+
+import com.google.common.truth.Correspondence;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Ignore;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.net.Inet6Address;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeoutException;
+
+/** Integration test cases for Service Discovery feature. */
+@RunWith(AndroidJUnit4.class)
+@RequiresThreadFeature
+@RequiresSimulationThreadDevice
+@LargeTest
+public class ServiceDiscoveryTest {
+    private static final String TAG = ServiceDiscoveryTest.class.getSimpleName();
+    private static final int NUM_FTD = 3;
+
+    // A valid Thread Active Operational Dataset generated from OpenThread CLI "dataset init new".
+    private static final byte[] DEFAULT_DATASET_TLVS =
+            base16().decode(
+                            "0E080000000000010000000300001335060004001FFFE002"
+                                    + "08ACC214689BC40BDF0708FD64DB1225F47E0B0510F26B31"
+                                    + "53760F519A63BAFDDFFC80D2AF030F4F70656E5468726561"
+                                    + "642D643961300102D9A00410A245479C836D551B9CA557F7"
+                                    + "B9D351B40C0402A0FFF8");
+    private static final ActiveOperationalDataset DEFAULT_DATASET =
+            ActiveOperationalDataset.fromThreadTlvs(DEFAULT_DATASET_TLVS);
+
+    private static final Correspondence<byte[], byte[]> BYTE_ARRAY_EQUALITY =
+            Correspondence.from(Arrays::equals, "is equivalent to");
+
+    @Rule public final ThreadFeatureCheckerRule mThreadRule = new ThreadFeatureCheckerRule();
+
+    private final Context mContext = ApplicationProvider.getApplicationContext();
+    private final ThreadNetworkControllerWrapper mController =
+            ThreadNetworkControllerWrapper.newInstance(mContext);
+    private final OtDaemonController mOtCtl = new OtDaemonController();
+    private HandlerThread mHandlerThread;
+    private NsdManager mNsdManager;
+    private TapTestNetworkTracker mTestNetworkTracker;
+    private final List<FullThreadDevice> mFtds = new ArrayList<>();
+    private final List<RegistrationListener> mRegistrationListeners = new ArrayList<>();
+
+    @Before
+    public void setUp() throws Exception {
+        mOtCtl.factoryReset();
+        mController.setEnabledAndWait(true);
+        mController.joinAndWait(DEFAULT_DATASET);
+        mNsdManager = mContext.getSystemService(NsdManager.class);
+
+        mHandlerThread = new HandlerThread(TAG);
+        mHandlerThread.start();
+
+        mTestNetworkTracker = new TapTestNetworkTracker(mContext, mHandlerThread.getLooper());
+        assertThat(mTestNetworkTracker).isNotNull();
+        mController.setTestNetworkAsUpstreamAndWait(mTestNetworkTracker.getInterfaceName());
+
+        // Create the FTDs in setUp() so that the FTDs can be safely released in tearDown().
+        // Don't create new FTDs in test cases.
+        for (int i = 0; i < NUM_FTD; ++i) {
+            FullThreadDevice ftd = new FullThreadDevice(10 + i /* node ID */);
+            ftd.autoStartSrpClient();
+            mFtds.add(ftd);
+        }
+    }
+
+    @After
+    public void tearDown() throws Exception {
+        for (RegistrationListener listener : mRegistrationListeners) {
+            unregisterService(listener);
+        }
+        for (FullThreadDevice ftd : mFtds) {
+            // Clear registered SRP hosts and services
+            if (ftd.isSrpHostRegistered()) {
+                ftd.removeSrpHost();
+            }
+            ftd.destroy();
+        }
+        if (mTestNetworkTracker != null) {
+            mTestNetworkTracker.tearDown();
+        }
+        if (mHandlerThread != null) {
+            mHandlerThread.quitSafely();
+            mHandlerThread.join();
+        }
+        mController.setTestNetworkAsUpstreamAndWait(null);
+        mController.leaveAndWait();
+    }
+
+    @Test
+    public void advertisingProxy_multipleSrpClientsRegisterServices_servicesResolvableByMdns()
+            throws Exception {
+        /*
+         * <pre>
+         * Topology:
+         *                    Thread
+         *  Border Router -------------- Full Thread device 1
+         *  (Cuttlefish)         |
+         *                       +------ Full Thread device 2
+         *                       |
+         *                       +------ Full Thread device 3
+         * </pre>
+         */
+
+        // Creates Full Thread Devices (FTD) and let them join the network.
+        for (FullThreadDevice ftd : mFtds) {
+            ftd.joinNetwork(DEFAULT_DATASET);
+            ftd.waitForStateAnyOf(List.of("router", "child"), JOIN_TIMEOUT);
+        }
+
+        int randomId = new Random().nextInt(10_000);
+
+        String serviceNamePrefix = "service-" + randomId + "-";
+        String serviceTypePrefix = "_test" + randomId;
+        String hostnamePrefix = "host-" + randomId + "-";
+
+        // For every FTD, let it register an SRP service.
+        for (int i = 0; i < mFtds.size(); ++i) {
+            FullThreadDevice ftd = mFtds.get(i);
+            ftd.setSrpHostname(hostnamePrefix + i);
+            ftd.setSrpHostAddresses(List.of(ftd.getOmrAddress(), ftd.getMlEid()));
+            ftd.addSrpService(
+                    serviceNamePrefix + i,
+                    serviceTypePrefix + i + "._tcp",
+                    List.of("_sub1", "_sub2"),
+                    12345 /* port */,
+                    Map.of("key1", bytes(0x01, 0x02), "key2", bytes(i)));
+        }
+
+        // Check the advertised services are discoverable and resolvable by NsdManager
+        for (int i = 0; i < mFtds.size(); ++i) {
+            NsdServiceInfo discoveredService =
+                    discoverService(mNsdManager, serviceTypePrefix + i + "._tcp");
+            assertThat(discoveredService).isNotNull();
+            NsdServiceInfo resolvedService = resolveService(mNsdManager, discoveredService);
+            assertThat(resolvedService.getServiceName()).isEqualTo(serviceNamePrefix + i);
+            assertThat(resolvedService.getServiceType()).isEqualTo(serviceTypePrefix + i + "._tcp");
+            assertThat(resolvedService.getPort()).isEqualTo(12345);
+            assertThat(resolvedService.getAttributes())
+                    .comparingValuesUsing(BYTE_ARRAY_EQUALITY)
+                    .containsExactly("key1", bytes(0x01, 0x02), "key2", bytes(i));
+            assertThat(resolvedService.getHostname()).isEqualTo(hostnamePrefix + i);
+            assertThat(resolvedService.getHostAddresses())
+                    .containsExactly(mFtds.get(i).getOmrAddress());
+        }
+    }
+
+    @Test
+    public void advertisingProxy_srpClientUpdatesService_updatedServiceResolvableByMdns()
+            throws Exception {
+        /*
+         * <pre>
+         * Topology:
+         *                    Thread
+         *  Border Router -------------- Full Thread device
+         *  (Cuttlefish)
+         * </pre>
+         */
+
+        // Creates a Full Thread Devices (FTD) and let it join the network.
+        FullThreadDevice ftd = mFtds.get(0);
+        ftd.joinNetwork(DEFAULT_DATASET);
+        ftd.waitForStateAnyOf(List.of("router", "child"), JOIN_TIMEOUT);
+        ftd.setSrpHostname("my-host");
+        ftd.setSrpHostAddresses(List.of((Inet6Address) parseNumericAddress("2001:db8::1")));
+        ftd.addSrpService(
+                "my-service",
+                "_test._tcp",
+                Collections.emptyList() /* subtypes */,
+                12345 /* port */,
+                Map.of("key1", bytes(0x01, 0x02), "key2", bytes(0x03)));
+
+        // Update the host addresses
+        ftd.setSrpHostAddresses(
+                List.of(
+                        (Inet6Address) parseNumericAddress("2001:db8::1"),
+                        (Inet6Address) parseNumericAddress("2001:db8::2")));
+        // Update the service
+        ftd.updateSrpService(
+                "my-service", "_test._tcp", List.of("_sub3"), 11111, Map.of("key1", bytes(0x04)));
+        waitFor(ftd::isSrpHostRegistered, SERVICE_DISCOVERY_TIMEOUT);
+
+        // Check the advertised service is discoverable and resolvable by NsdManager
+        NsdServiceInfo discoveredService = discoverService(mNsdManager, "_test._tcp");
+        assertThat(discoveredService).isNotNull();
+        NsdServiceInfo resolvedService =
+                resolveServiceUntil(
+                        mNsdManager,
+                        discoveredService,
+                        s -> s.getPort() == 11111 && s.getHostAddresses().size() == 2);
+        assertThat(resolvedService.getServiceName()).isEqualTo("my-service");
+        assertThat(resolvedService.getServiceType()).isEqualTo("_test._tcp");
+        assertThat(resolvedService.getPort()).isEqualTo(11111);
+        assertThat(resolvedService.getAttributes())
+                .comparingValuesUsing(BYTE_ARRAY_EQUALITY)
+                .containsExactly("key1", bytes(0x04));
+        assertThat(resolvedService.getHostname()).isEqualTo("my-host");
+        assertThat(resolvedService.getHostAddresses())
+                .containsExactly(
+                        parseNumericAddress("2001:db8::1"), parseNumericAddress("2001:db8::2"));
+    }
+
+    @Test
+    public void advertisingProxy_srpClientUnregistersService_serviceIsNotDiscoverableByMdns()
+            throws Exception {
+        /*
+         * <pre>
+         * Topology:
+         *                    Thread
+         *  Border Router -------------- Full Thread device
+         *  (Cuttlefish)
+         * </pre>
+         */
+
+        // Creates a Full Thread Devices (FTD) and let it join the network.
+        FullThreadDevice ftd = mFtds.get(0);
+        ftd.joinNetwork(DEFAULT_DATASET);
+        ftd.waitForStateAnyOf(List.of("router", "child"), JOIN_TIMEOUT);
+        ftd.setSrpHostname("my-host");
+        ftd.setSrpHostAddresses(
+                List.of(
+                        (Inet6Address) parseNumericAddress("2001:db8::1"),
+                        (Inet6Address) parseNumericAddress("2001:db8::2")));
+        ftd.addSrpService(
+                "my-service",
+                "_test._udp",
+                List.of("_sub1"),
+                12345 /* port */,
+                Map.of("key1", bytes(0x01, 0x02), "key2", bytes(0x03)));
+        // Wait for the service to be discoverable by NsdManager.
+        assertThat(discoverService(mNsdManager, "_test._udp")).isNotNull();
+
+        // Unregister the service.
+        CompletableFuture<NsdServiceInfo> serviceLostFuture = new CompletableFuture<>();
+        NsdManager.DiscoveryListener listener =
+                discoverForServiceLost(mNsdManager, "_test._udp", serviceLostFuture);
+        ftd.removeSrpService("my-service", "_test._udp", true /* notifyServer */);
+
+        // Verify the service becomes lost.
+        try {
+            serviceLostFuture.get(SERVICE_DISCOVERY_TIMEOUT.toMillis(), MILLISECONDS);
+        } finally {
+            mNsdManager.stopServiceDiscovery(listener);
+        }
+        assertThrows(TimeoutException.class, () -> discoverService(mNsdManager, "_test._udp"));
+    }
+
+    @Test
+    public void meshcopOverlay_vendorAndModelNameAreSetToOverlayValue() throws Exception {
+        NsdServiceInfo discoveredService = discoverService(mNsdManager, "_meshcop._udp");
+        assertThat(discoveredService).isNotNull();
+        NsdServiceInfo meshcopService = resolveService(mNsdManager, discoveredService);
+
+        Map<String, byte[]> txtMap = meshcopService.getAttributes();
+        assertThat(txtMap.get("vn")).isEqualTo("Android".getBytes(UTF_8));
+        assertThat(txtMap.get("mn")).isEqualTo("Thread Border Router".getBytes(UTF_8));
+    }
+
+    @Test
+    @Ignore("TODO: b/332452386 - Enable this test case when it handles the multi-client case well")
+    public void discoveryProxy_multipleClientsBrowseAndResolveServiceOverMdns() throws Exception {
+        /*
+         * <pre>
+         * Topology:
+         *                    Thread
+         *  Border Router -------------- Full Thread device
+         *  (Cuttlefish)
+         * </pre>
+         */
+
+        RegistrationListener listener = new RegistrationListener();
+        NsdServiceInfo info = new NsdServiceInfo();
+        info.setServiceType("_testservice._tcp");
+        info.setServiceName("test-service");
+        info.setPort(12345);
+        info.setHostname("testhost");
+        info.setHostAddresses(List.of(parseNumericAddress("2001::1")));
+        info.setAttribute("key1", bytes(0x01, 0x02));
+        info.setAttribute("key2", bytes(0x03));
+        registerService(info, listener);
+        mRegistrationListeners.add(listener);
+        for (int i = 0; i < NUM_FTD; ++i) {
+            FullThreadDevice ftd = mFtds.get(i);
+            ftd.joinNetwork(DEFAULT_DATASET);
+            ftd.waitForStateAnyOf(List.of("router", "child"), JOIN_TIMEOUT);
+            ftd.setDnsServerAddress(mOtCtl.getMlEid().getHostAddress());
+        }
+        final ArrayList<NsdServiceInfo> browsedServices = new ArrayList<>();
+        final ArrayList<NsdServiceInfo> resolvedServices = new ArrayList<>();
+        final ArrayList<Thread> threads = new ArrayList<>();
+        for (int i = 0; i < NUM_FTD; ++i) {
+            browsedServices.add(null);
+            resolvedServices.add(null);
+        }
+        for (int i = 0; i < NUM_FTD; ++i) {
+            final FullThreadDevice ftd = mFtds.get(i);
+            final int index = i;
+            Runnable task =
+                    () -> {
+                        browsedServices.set(
+                                index,
+                                ftd.browseService("_testservice._tcp.default.service.arpa."));
+                        resolvedServices.set(
+                                index,
+                                ftd.resolveService(
+                                        "test-service", "_testservice._tcp.default.service.arpa."));
+                    };
+            threads.add(new Thread(task));
+        }
+        for (Thread thread : threads) {
+            thread.start();
+        }
+        for (Thread thread : threads) {
+            thread.join();
+        }
+
+        for (int i = 0; i < NUM_FTD; ++i) {
+            NsdServiceInfo browsedService = browsedServices.get(i);
+            assertThat(browsedService.getServiceName()).isEqualTo("test-service");
+            assertThat(browsedService.getPort()).isEqualTo(12345);
+
+            NsdServiceInfo resolvedService = resolvedServices.get(i);
+            assertThat(resolvedService.getServiceName()).isEqualTo("test-service");
+            assertThat(resolvedService.getPort()).isEqualTo(12345);
+            assertThat(resolvedService.getHostname()).isEqualTo("testhost.default.service.arpa.");
+            assertThat(resolvedService.getHostAddresses())
+                    .containsExactly(parseNumericAddress("2001::1"));
+            assertThat(resolvedService.getAttributes())
+                    .comparingValuesUsing(BYTE_ARRAY_EQUALITY)
+                    .containsExactly("key1", bytes(0x01, 0x02), "key2", bytes(3));
+        }
+    }
+
+    @Test
+    public void discoveryProxy_browseAndResolveServiceAtSrpServer() throws Exception {
+        /*
+         * <pre>
+         * Topology:
+         *                    Thread
+         *  Border Router -------+------ SRP client
+         *  (Cuttlefish)         |
+         *                       +------ DNS client
+         *
+         * </pre>
+         */
+        FullThreadDevice srpClient = mFtds.get(0);
+        srpClient.joinNetwork(DEFAULT_DATASET);
+        srpClient.waitForStateAnyOf(List.of("router", "child"), JOIN_TIMEOUT);
+        srpClient.setSrpHostname("my-host");
+        srpClient.setSrpHostAddresses(List.of((Inet6Address) parseNumericAddress("2001::1")));
+        srpClient.addSrpService(
+                "my-service",
+                "_test._udp",
+                List.of("_sub1"),
+                12345 /* port */,
+                Map.of("key1", bytes(0x01, 0x02), "key2", bytes(0x03)));
+
+        FullThreadDevice dnsClient = mFtds.get(1);
+        dnsClient.joinNetwork(DEFAULT_DATASET);
+        dnsClient.waitForStateAnyOf(List.of("router", "child"), JOIN_TIMEOUT);
+        dnsClient.setDnsServerAddress(mOtCtl.getMlEid().getHostAddress());
+
+        NsdServiceInfo browsedService = dnsClient.browseService("_test._udp.default.service.arpa.");
+        assertThat(browsedService.getServiceName()).isEqualTo("my-service");
+        assertThat(browsedService.getPort()).isEqualTo(12345);
+        assertThat(browsedService.getHostname()).isEqualTo("my-host.default.service.arpa.");
+        assertThat(browsedService.getHostAddresses())
+                .containsExactly(parseNumericAddress("2001::1"));
+        assertThat(browsedService.getAttributes())
+                .comparingValuesUsing(BYTE_ARRAY_EQUALITY)
+                .containsExactly("key1", bytes(0x01, 0x02), "key2", bytes(3));
+
+        NsdServiceInfo resolvedService =
+                dnsClient.resolveService("my-service", "_test._udp.default.service.arpa.");
+        assertThat(resolvedService.getServiceName()).isEqualTo("my-service");
+        assertThat(resolvedService.getPort()).isEqualTo(12345);
+        assertThat(resolvedService.getHostname()).isEqualTo("my-host.default.service.arpa.");
+        assertThat(resolvedService.getHostAddresses())
+                .containsExactly(parseNumericAddress("2001::1"));
+        assertThat(resolvedService.getAttributes())
+                .comparingValuesUsing(BYTE_ARRAY_EQUALITY)
+                .containsExactly("key1", bytes(0x01, 0x02), "key2", bytes(3));
+    }
+
+    private void registerService(NsdServiceInfo serviceInfo, RegistrationListener listener)
+            throws InterruptedException, ExecutionException, TimeoutException {
+        mNsdManager.registerService(serviceInfo, PROTOCOL_DNS_SD, listener);
+        listener.waitForRegistered();
+    }
+
+    private void unregisterService(RegistrationListener listener)
+            throws InterruptedException, ExecutionException, TimeoutException {
+        mNsdManager.unregisterService(listener);
+        listener.waitForUnregistered();
+    }
+
+    private static class RegistrationListener implements NsdManager.RegistrationListener {
+        private final CompletableFuture<Void> mRegisteredFuture = new CompletableFuture<>();
+        private final CompletableFuture<Void> mUnRegisteredFuture = new CompletableFuture<>();
+
+        RegistrationListener() {}
+
+        @Override
+        public void onRegistrationFailed(NsdServiceInfo serviceInfo, int errorCode) {}
+
+        @Override
+        public void onUnregistrationFailed(NsdServiceInfo serviceInfo, int errorCode) {}
+
+        @Override
+        public void onServiceRegistered(NsdServiceInfo serviceInfo) {
+            mRegisteredFuture.complete(null);
+        }
+
+        @Override
+        public void onServiceUnregistered(NsdServiceInfo serviceInfo) {
+            mUnRegisteredFuture.complete(null);
+        }
+
+        public void waitForRegistered()
+                throws InterruptedException, ExecutionException, TimeoutException {
+            mRegisteredFuture.get(SERVICE_DISCOVERY_TIMEOUT.toMillis(), MILLISECONDS);
+        }
+
+        public void waitForUnregistered()
+                throws InterruptedException, ExecutionException, TimeoutException {
+            mUnRegisteredFuture.get(SERVICE_DISCOVERY_TIMEOUT.toMillis(), MILLISECONDS);
+        }
+    }
+
+    private static byte[] bytes(int... byteInts) {
+        byte[] bytes = new byte[byteInts.length];
+        for (int i = 0; i < byteInts.length; ++i) {
+            bytes[i] = (byte) byteInts[i];
+        }
+        return bytes;
+    }
+}
diff --git a/thread/tests/integration/src/android/net/thread/ThreadIntegrationTest.java b/thread/tests/integration/src/android/net/thread/ThreadIntegrationTest.java
index 70897f0..c70f3af 100644
--- a/thread/tests/integration/src/android/net/thread/ThreadIntegrationTest.java
+++ b/thread/tests/integration/src/android/net/thread/ThreadIntegrationTest.java
@@ -16,31 +16,23 @@
 
 package android.net.thread;
 
-import static android.Manifest.permission.NETWORK_SETTINGS;
 import static android.net.thread.ThreadNetworkController.DEVICE_ROLE_DETACHED;
 import static android.net.thread.ThreadNetworkController.DEVICE_ROLE_LEADER;
 import static android.net.thread.ThreadNetworkController.DEVICE_ROLE_STOPPED;
-import static android.net.thread.ThreadNetworkManager.PERMISSION_THREAD_NETWORK_PRIVILEGED;
 import static android.net.thread.utils.IntegrationTestUtils.CALLBACK_TIMEOUT;
-import static android.net.thread.utils.IntegrationTestUtils.LEAVE_TIMEOUT;
 import static android.net.thread.utils.IntegrationTestUtils.RESTART_JOIN_TIMEOUT;
-import static android.net.thread.utils.IntegrationTestUtils.waitForStateAnyOf;
 
 import static com.android.compatibility.common.util.SystemUtil.runShellCommand;
-import static com.android.testutils.TestPermissionUtil.runAsShell;
+import static com.android.compatibility.common.util.SystemUtil.runShellCommandOrThrow;
 
 import static com.google.common.io.BaseEncoding.base16;
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
 
-import static org.junit.Assume.assumeNotNull;
-
-import static java.util.concurrent.TimeUnit.MILLISECONDS;
-
-import android.annotation.Nullable;
 import android.content.Context;
-import android.net.thread.ThreadNetworkController.StateCallback;
 import android.net.thread.utils.OtDaemonController;
+import android.net.thread.utils.ThreadFeatureCheckerRule;
+import android.net.thread.utils.ThreadFeatureCheckerRule.RequiresThreadFeature;
+import android.net.thread.utils.ThreadNetworkControllerWrapper;
 import android.os.SystemClock;
 
 import androidx.test.core.app.ApplicationProvider;
@@ -49,21 +41,18 @@
 
 import org.junit.After;
 import org.junit.Before;
+import org.junit.Rule;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
 import java.net.Inet6Address;
 import java.util.List;
-import java.util.concurrent.CompletableFuture;
 
 /** Tests for E2E Android Thread integration with ot-daemon, ConnectivityService, etc.. */
 @LargeTest
+@RequiresThreadFeature
 @RunWith(AndroidJUnit4.class)
 public class ThreadIntegrationTest {
-    private final Context mContext = ApplicationProvider.getApplicationContext();
-    private ThreadNetworkController mController;
-    private OtDaemonController mOtCtl;
-
     // A valid Thread Active Operational Dataset generated from OpenThread CLI "dataset init new".
     private static final byte[] DEFAULT_DATASET_TLVS =
             base16().decode(
@@ -75,73 +64,74 @@
     private static final ActiveOperationalDataset DEFAULT_DATASET =
             ActiveOperationalDataset.fromThreadTlvs(DEFAULT_DATASET_TLVS);
 
+    @Rule public final ThreadFeatureCheckerRule mThreadRule = new ThreadFeatureCheckerRule();
+
+    private final Context mContext = ApplicationProvider.getApplicationContext();
+    private final ThreadNetworkControllerWrapper mController =
+            ThreadNetworkControllerWrapper.newInstance(mContext);
+    private OtDaemonController mOtCtl;
+
     @Before
     public void setUp() throws Exception {
-        final ThreadNetworkManager manager = mContext.getSystemService(ThreadNetworkManager.class);
-        if (manager != null) {
-            mController = manager.getAllThreadNetworkControllers().get(0);
-        }
-
-        // Run the tests on only devices where the Thread feature is available
-        assumeNotNull(mController);
-
         mOtCtl = new OtDaemonController();
-        leaveAndWait(mController);
+        mController.leaveAndWait();
+
+        // TODO: b/323301831 - This is a workaround to avoid unnecessary delay to re-form a network
+        mOtCtl.factoryReset();
     }
 
     @After
     public void tearDown() throws Exception {
-        if (mController == null) {
-            return;
-        }
-
-        setTestUpStreamNetworkAndWait(mController, null);
-        leaveAndWait(mController);
+        mController.setTestNetworkAsUpstreamAndWait(null);
+        mController.leaveAndWait();
     }
 
     @Test
     public void otDaemonRestart_notJoinedAndStopped_deviceRoleIsStopped() throws Exception {
-        leaveAndWait(mController);
+        mController.leaveAndWait();
 
         runShellCommand("stop ot-daemon");
         // TODO(b/323331973): the sleep is needed to workaround the race conditions
         SystemClock.sleep(200);
 
-        waitForStateAnyOf(mController, List.of(DEVICE_ROLE_STOPPED), CALLBACK_TIMEOUT);
+        mController.waitForRole(DEVICE_ROLE_STOPPED, CALLBACK_TIMEOUT);
     }
 
     @Test
-    public void otDaemonRestart_JoinedNetworkAndStopped_autoRejoined() throws Exception {
-        joinAndWait(mController, DEFAULT_DATASET);
+    public void otDaemonRestart_JoinedNetworkAndStopped_autoRejoinedAndTunIfStateConsistent()
+            throws Exception {
+        mController.joinAndWait(DEFAULT_DATASET);
 
         runShellCommand("stop ot-daemon");
 
-        waitForStateAnyOf(mController, List.of(DEVICE_ROLE_DETACHED), CALLBACK_TIMEOUT);
-        waitForStateAnyOf(mController, List.of(DEVICE_ROLE_LEADER), RESTART_JOIN_TIMEOUT);
+        mController.waitForRole(DEVICE_ROLE_DETACHED, CALLBACK_TIMEOUT);
+        mController.waitForRole(DEVICE_ROLE_LEADER, RESTART_JOIN_TIMEOUT);
+        assertThat(mOtCtl.isInterfaceUp()).isTrue();
+        assertThat(runShellCommand("ifconfig thread-wpan")).contains("UP POINTOPOINT RUNNING");
     }
 
     @Test
     public void otDaemonFactoryReset_deviceRoleIsStopped() throws Exception {
-        joinAndWait(mController, DEFAULT_DATASET);
+        mController.joinAndWait(DEFAULT_DATASET);
 
         mOtCtl.factoryReset();
 
-        assertThat(getDeviceRole(mController)).isEqualTo(DEVICE_ROLE_STOPPED);
+        assertThat(mController.getDeviceRole()).isEqualTo(DEVICE_ROLE_STOPPED);
     }
 
     @Test
     public void otDaemonFactoryReset_addressesRemoved() throws Exception {
-        joinAndWait(mController, DEFAULT_DATASET);
+        mController.joinAndWait(DEFAULT_DATASET);
 
         mOtCtl.factoryReset();
-        String ifconfig = runShellCommand("ifconfig thread-wpan");
 
+        String ifconfig = runShellCommand("ifconfig thread-wpan");
         assertThat(ifconfig).doesNotContain("inet6 addr");
     }
 
     @Test
     public void tunInterface_joinedNetwork_otAddressesAddedToTunInterface() throws Exception {
-        joinAndWait(mController, DEFAULT_DATASET);
+        mController.joinAndWait(DEFAULT_DATASET);
 
         String ifconfig = runShellCommand("ifconfig thread-wpan");
         List<Inet6Address> otAddresses = mOtCtl.getAddresses();
@@ -151,48 +141,22 @@
         }
     }
 
+    @Test
+    public void otDaemonRestart_latestCountryCodeIsSetToOtDaemon() throws Exception {
+        runThreadCommand("force-country-code enabled CN");
+
+        runShellCommand("stop ot-daemon");
+        // TODO(b/323331973): the sleep is needed to workaround the race conditions
+        SystemClock.sleep(200);
+        mController.waitForRole(DEVICE_ROLE_STOPPED, CALLBACK_TIMEOUT);
+
+        assertThat(mOtCtl.getCountryCode()).isEqualTo("CN");
+    }
+
+    private static String runThreadCommand(String cmd) {
+        return runShellCommandOrThrow("cmd thread_network " + cmd);
+    }
+
     // TODO (b/323300829): add more tests for integration with linux platform and
     // ConnectivityService
-
-    private static int getDeviceRole(ThreadNetworkController controller) throws Exception {
-        CompletableFuture<Integer> future = new CompletableFuture<>();
-        StateCallback callback = future::complete;
-        controller.registerStateCallback(directExecutor(), callback);
-        try {
-            return future.get(CALLBACK_TIMEOUT.toMillis(), MILLISECONDS);
-        } finally {
-            controller.unregisterStateCallback(callback);
-        }
-    }
-
-    private static void joinAndWait(
-            ThreadNetworkController controller, ActiveOperationalDataset activeDataset)
-            throws Exception {
-        runAsShell(
-                PERMISSION_THREAD_NETWORK_PRIVILEGED,
-                () -> controller.join(activeDataset, directExecutor(), result -> {}));
-        waitForStateAnyOf(controller, List.of(DEVICE_ROLE_LEADER), RESTART_JOIN_TIMEOUT);
-    }
-
-    private static void leaveAndWait(ThreadNetworkController controller) throws Exception {
-        CompletableFuture<Void> future = new CompletableFuture<>();
-        runAsShell(
-                PERMISSION_THREAD_NETWORK_PRIVILEGED,
-                () -> controller.leave(directExecutor(), future::complete));
-        future.get(LEAVE_TIMEOUT.toMillis(), MILLISECONDS);
-    }
-
-    private static void setTestUpStreamNetworkAndWait(
-            ThreadNetworkController controller, @Nullable String networkInterfaceName)
-            throws Exception {
-        CompletableFuture<Void> future = new CompletableFuture<>();
-        runAsShell(
-                PERMISSION_THREAD_NETWORK_PRIVILEGED,
-                NETWORK_SETTINGS,
-                () -> {
-                    controller.setTestNetworkAsUpstream(
-                            networkInterfaceName, directExecutor(), future::complete);
-                });
-        future.get(CALLBACK_TIMEOUT.toMillis(), MILLISECONDS);
-    }
 }
diff --git a/thread/tests/integration/src/android/net/thread/ThreadNetworkControllerTest.java b/thread/tests/integration/src/android/net/thread/ThreadNetworkControllerTest.java
new file mode 100644
index 0000000..ba04348
--- /dev/null
+++ b/thread/tests/integration/src/android/net/thread/ThreadNetworkControllerTest.java
@@ -0,0 +1,171 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net.thread;
+
+import static android.net.thread.ThreadNetworkException.ERROR_UNSUPPORTED_OPERATION;
+
+import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation;
+
+import static com.android.testutils.TestPermissionUtil.runAsShell;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertThrows;
+
+import android.content.Context;
+import android.net.thread.utils.ThreadFeatureCheckerRule;
+import android.net.thread.utils.ThreadFeatureCheckerRule.RequiresThreadFeature;
+import android.os.OutcomeReceiver;
+import android.util.SparseIntArray;
+
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.filters.LargeTest;
+import androidx.test.runner.AndroidJUnit4;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+/** Tests for hide methods of {@link ThreadNetworkController}. */
+@LargeTest
+@RequiresThreadFeature
+@RunWith(AndroidJUnit4.class)
+public class ThreadNetworkControllerTest {
+    private static final int VALID_POWER = 32_767;
+    private static final int INVALID_POWER = 32_768;
+    private static final int VALID_CHANNEL = 20;
+    private static final int INVALID_CHANNEL = 10;
+    private static final String THREAD_NETWORK_PRIVILEGED =
+            "android.permission.THREAD_NETWORK_PRIVILEGED";
+
+    private static final SparseIntArray CHANNEL_MAX_POWERS =
+            new SparseIntArray() {
+                {
+                    put(20, 32767);
+                }
+            };
+
+    @Rule public final ThreadFeatureCheckerRule mThreadRule = new ThreadFeatureCheckerRule();
+
+    private final Context mContext = ApplicationProvider.getApplicationContext();
+    private ExecutorService mExecutor;
+    private ThreadNetworkController mController;
+
+    @Before
+    public void setUp() throws Exception {
+        mController =
+                mContext.getSystemService(ThreadNetworkManager.class)
+                        .getAllThreadNetworkControllers()
+                        .get(0);
+
+        mExecutor = Executors.newSingleThreadExecutor();
+    }
+
+    @After
+    public void tearDown() throws Exception {
+        dropAllPermissions();
+    }
+
+    @Test
+    public void setChannelMaxPowers_withPrivilegedPermission_success() throws Exception {
+        CompletableFuture<Void> powerFuture = new CompletableFuture<>();
+
+        runAsShell(
+                THREAD_NETWORK_PRIVILEGED,
+                () ->
+                        mController.setChannelMaxPowers(
+                                CHANNEL_MAX_POWERS, mExecutor, newOutcomeReceiver(powerFuture)));
+
+        try {
+            assertThat(powerFuture.get()).isNull();
+        } catch (ExecutionException exception) {
+            ThreadNetworkException thrown = (ThreadNetworkException) exception.getCause();
+            assertThat(thrown.getErrorCode()).isEqualTo(ERROR_UNSUPPORTED_OPERATION);
+        }
+    }
+
+    @Test
+    public void setChannelMaxPowers_withoutPrivilegedPermission_throwsSecurityException()
+            throws Exception {
+        dropAllPermissions();
+
+        assertThrows(
+                SecurityException.class,
+                () -> mController.setChannelMaxPowers(CHANNEL_MAX_POWERS, mExecutor, v -> {}));
+    }
+
+    @Test
+    public void setChannelMaxPowers_emptyChannelMaxPower_throwsIllegalArgumentException() {
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> mController.setChannelMaxPowers(new SparseIntArray(), mExecutor, v -> {}));
+    }
+
+    @Test
+    public void setChannelMaxPowers_invalidChannel_throwsIllegalArgumentException() {
+        final SparseIntArray INVALID_CHANNEL_ARRAY =
+                new SparseIntArray() {
+                    {
+                        put(INVALID_CHANNEL, VALID_POWER);
+                    }
+                };
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> mController.setChannelMaxPowers(INVALID_CHANNEL_ARRAY, mExecutor, v -> {}));
+    }
+
+    @Test
+    public void setChannelMaxPowers_invalidPower_throwsIllegalArgumentException() {
+        final SparseIntArray INVALID_POWER_ARRAY =
+                new SparseIntArray() {
+                    {
+                        put(VALID_CHANNEL, INVALID_POWER);
+                    }
+                };
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> mController.setChannelMaxPowers(INVALID_POWER_ARRAY, mExecutor, v -> {}));
+    }
+
+    private static void dropAllPermissions() {
+        getInstrumentation().getUiAutomation().dropShellPermissionIdentity();
+    }
+
+    private static <V> OutcomeReceiver<V, ThreadNetworkException> newOutcomeReceiver(
+            CompletableFuture<V> future) {
+        return new OutcomeReceiver<V, ThreadNetworkException>() {
+            @Override
+            public void onResult(V result) {
+                future.complete(result);
+            }
+
+            @Override
+            public void onError(ThreadNetworkException e) {
+                future.completeExceptionally(e);
+            }
+        };
+    }
+}
diff --git a/thread/tests/integration/src/android/net/thread/ThreadNetworkShellCommandTest.java b/thread/tests/integration/src/android/net/thread/ThreadNetworkShellCommandTest.java
new file mode 100644
index 0000000..8835f40
--- /dev/null
+++ b/thread/tests/integration/src/android/net/thread/ThreadNetworkShellCommandTest.java
@@ -0,0 +1,129 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net.thread;
+
+import static android.net.thread.ThreadNetworkController.STATE_DISABLED;
+import static android.net.thread.ThreadNetworkController.STATE_ENABLED;
+import static android.net.thread.ThreadNetworkException.ERROR_THREAD_DISABLED;
+
+import static com.android.compatibility.common.util.SystemUtil.runShellCommandOrThrow;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertThrows;
+
+import android.content.Context;
+import android.net.thread.utils.ThreadFeatureCheckerRule;
+import android.net.thread.utils.ThreadFeatureCheckerRule.RequiresThreadFeature;
+import android.net.thread.utils.ThreadNetworkControllerWrapper;
+
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.filters.LargeTest;
+import androidx.test.runner.AndroidJUnit4;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.concurrent.ExecutionException;
+
+/** Integration tests for {@link ThreadNetworkShellCommand}. */
+@LargeTest
+@RequiresThreadFeature
+@RunWith(AndroidJUnit4.class)
+public class ThreadNetworkShellCommandTest {
+    @Rule public final ThreadFeatureCheckerRule mThreadRule = new ThreadFeatureCheckerRule();
+
+    private final Context mContext = ApplicationProvider.getApplicationContext();
+    private final ThreadNetworkControllerWrapper mController =
+            ThreadNetworkControllerWrapper.newInstance(mContext);
+
+    @Before
+    public void setUp() {
+        ensureThreadEnabled();
+    }
+
+    @After
+    public void tearDown() {
+        ensureThreadEnabled();
+    }
+
+    private static void ensureThreadEnabled() {
+        runThreadCommand("force-stop-ot-daemon disabled");
+        runThreadCommand("enable");
+    }
+
+    @Test
+    public void enable_threadStateIsEnabled() throws Exception {
+        runThreadCommand("enable");
+
+        assertThat(mController.getEnabledState()).isEqualTo(STATE_ENABLED);
+    }
+
+    @Test
+    public void disable_threadStateIsDisabled() throws Exception {
+        runThreadCommand("disable");
+
+        assertThat(mController.getEnabledState()).isEqualTo(STATE_DISABLED);
+    }
+
+    @Test
+    public void forceStopOtDaemon_forceStopEnabled_otDaemonServiceDisappear() {
+        runThreadCommand("force-stop-ot-daemon enabled");
+
+        assertThat(runShellCommandOrThrow("service list")).doesNotContain("ot_daemon");
+    }
+
+    @Test
+    public void forceStopOtDaemon_forceStopEnabled_canNotEnableThread() throws Exception {
+        runThreadCommand("force-stop-ot-daemon enabled");
+
+        ExecutionException thrown =
+                assertThrows(ExecutionException.class, () -> mController.setEnabledAndWait(true));
+        ThreadNetworkException cause = (ThreadNetworkException) thrown.getCause();
+        assertThat(cause.getErrorCode()).isEqualTo(ERROR_THREAD_DISABLED);
+    }
+
+    @Test
+    public void forceStopOtDaemon_forceStopDisabled_otDaemonServiceAppears() throws Exception {
+        runThreadCommand("force-stop-ot-daemon disabled");
+
+        assertThat(runShellCommandOrThrow("service list")).contains("ot_daemon");
+    }
+
+    @Test
+    public void forceStopOtDaemon_forceStopDisabled_canEnableThread() throws Exception {
+        runThreadCommand("force-stop-ot-daemon disabled");
+
+        mController.setEnabledAndWait(true);
+        assertThat(mController.getEnabledState()).isEqualTo(STATE_ENABLED);
+    }
+
+    @Test
+    public void forceCountryCode_setCN_getCountryCodeReturnsCN() {
+        runThreadCommand("force-country-code enabled CN");
+
+        final String result = runThreadCommand("get-country-code");
+        assertThat(result).contains("Thread country code = CN");
+    }
+
+    private static String runThreadCommand(String cmd) {
+        return runShellCommandOrThrow("cmd thread_network " + cmd);
+    }
+}
diff --git a/thread/tests/integration/src/android/net/thread/utils/FullThreadDevice.java b/thread/tests/integration/src/android/net/thread/utils/FullThreadDevice.java
index 6cb1675..f7bb9ff 100644
--- a/thread/tests/integration/src/android/net/thread/utils/FullThreadDevice.java
+++ b/thread/tests/integration/src/android/net/thread/utils/FullThreadDevice.java
@@ -15,6 +15,7 @@
  */
 package android.net.thread.utils;
 
+import static android.net.thread.utils.IntegrationTestUtils.SERVICE_DISCOVERY_TIMEOUT;
 import static android.net.thread.utils.IntegrationTestUtils.waitFor;
 
 import static com.google.common.io.BaseEncoding.base16;
@@ -23,17 +24,23 @@
 
 import android.net.InetAddresses;
 import android.net.IpPrefix;
+import android.net.nsd.NsdServiceInfo;
 import android.net.thread.ActiveOperationalDataset;
 
+import com.google.errorprone.annotations.FormatMethod;
+
 import java.io.BufferedReader;
 import java.io.BufferedWriter;
 import java.io.IOException;
 import java.io.InputStreamReader;
 import java.io.OutputStreamWriter;
 import java.net.Inet6Address;
+import java.net.InetAddress;
+import java.nio.charset.StandardCharsets;
 import java.time.Duration;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Map;
 import java.util.concurrent.TimeoutException;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
@@ -47,6 +54,13 @@
  * available commands.
  */
 public final class FullThreadDevice {
+    private static final int HOP_LIMIT = 64;
+    private static final int PING_INTERVAL = 1;
+    private static final int PING_SIZE = 100;
+    // There may not be a response for the ping command, using a short timeout to keep the tests
+    // short.
+    private static final float PING_TIMEOUT_SECONDS = 0.1f;
+
     private final Process mProcess;
     private final BufferedReader mReader;
     private final BufferedWriter mWriter;
@@ -191,7 +205,7 @@
     public void udpBind(Inet6Address address, int port) {
         udpClose();
         udpOpen();
-        executeCommand(String.format("udp bind %s %d", address.getHostAddress(), port));
+        executeCommand("udp bind %s %d", address.getHostAddress(), port);
     }
 
     /** Returns the message received on the UDP socket. */
@@ -204,6 +218,166 @@
         return matcher.group(4);
     }
 
+    /** Enables the SRP client and run in autostart mode. */
+    public void autoStartSrpClient() {
+        executeCommand("srp client autostart enable");
+    }
+
+    /** Sets the hostname (e.g. "MyHost") for the SRP client. */
+    public void setSrpHostname(String hostname) {
+        executeCommand("srp client host name " + hostname);
+    }
+
+    /** Sets the host addresses for the SRP client. */
+    public void setSrpHostAddresses(List<Inet6Address> addresses) {
+        executeCommand(
+                "srp client host address "
+                        + String.join(
+                                " ",
+                                addresses.stream().map(Inet6Address::getHostAddress).toList()));
+    }
+
+    /** Removes the SRP host */
+    public void removeSrpHost() {
+        executeCommand("srp client host remove 1 1");
+    }
+
+    /**
+     * Adds an SRP service for the SRP client and wait for the registration to complete.
+     *
+     * @param serviceName the service name like "MyService"
+     * @param serviceType the service type like "_test._tcp"
+     * @param subtypes the service subtypes like "_sub1"
+     * @param port the port number in range [1, 65535]
+     * @param txtMap the map of TXT names and values
+     * @throws TimeoutException if the service isn't registered within timeout
+     */
+    public void addSrpService(
+            String serviceName,
+            String serviceType,
+            List<String> subtypes,
+            int port,
+            Map<String, byte[]> txtMap)
+            throws TimeoutException {
+        StringBuilder fullServiceType = new StringBuilder(serviceType);
+        for (String subtype : subtypes) {
+            fullServiceType.append(",").append(subtype);
+        }
+        executeCommand(
+                "srp client service add %s %s %d %d %d %s",
+                serviceName,
+                fullServiceType,
+                port,
+                0 /* priority */,
+                0 /* weight */,
+                txtMapToHexString(txtMap));
+        waitFor(() -> isSrpServiceRegistered(serviceName, serviceType), SERVICE_DISCOVERY_TIMEOUT);
+    }
+
+    /**
+     * Removes an SRP service for the SRP client.
+     *
+     * @param serviceName the service name like "MyService"
+     * @param serviceType the service type like "_test._tcp"
+     * @param notifyServer whether to notify SRP server about the removal
+     */
+    public void removeSrpService(String serviceName, String serviceType, boolean notifyServer) {
+        String verb = notifyServer ? "remove" : "clear";
+        executeCommand("srp client service %s %s %s", verb, serviceName, serviceType);
+    }
+
+    /**
+     * Updates an existing SRP service for the SRP client.
+     *
+     * <p>This is essentially a 'remove' and an 'add' on the SRP client's side.
+     *
+     * @param serviceName the service name like "MyService"
+     * @param serviceType the service type like "_test._tcp"
+     * @param subtypes the service subtypes like "_sub1"
+     * @param port the port number in range [1, 65535]
+     * @param txtMap the map of TXT names and values
+     * @throws TimeoutException if the service isn't updated within timeout
+     */
+    public void updateSrpService(
+            String serviceName,
+            String serviceType,
+            List<String> subtypes,
+            int port,
+            Map<String, byte[]> txtMap)
+            throws TimeoutException {
+        removeSrpService(serviceName, serviceType, false /* notifyServer */);
+        addSrpService(serviceName, serviceType, subtypes, port, txtMap);
+    }
+
+    /** Checks if an SRP service is registered. */
+    public boolean isSrpServiceRegistered(String serviceName, String serviceType) {
+        List<String> lines = executeCommand("srp client service");
+        for (String line : lines) {
+            if (line.contains(serviceName) && line.contains(serviceType)) {
+                return line.contains("Registered");
+            }
+        }
+        return false;
+    }
+
+    /** Checks if an SRP host is registered. */
+    public boolean isSrpHostRegistered() {
+        List<String> lines = executeCommand("srp client host");
+        for (String line : lines) {
+            return line.contains("Registered");
+        }
+        return false;
+    }
+
+    /** Sets the DNS server address. */
+    public void setDnsServerAddress(String address) {
+        executeCommand("dns config " + address);
+    }
+
+    /** Returns the first browsed service instance of {@code serviceType}. */
+    public NsdServiceInfo browseService(String serviceType) {
+        // CLI output:
+        // DNS browse response for _testservice._tcp.default.service.arpa.
+        // test-service
+        //    Port:12345, Priority:0, Weight:0, TTL:10
+        //    Host:testhost.default.service.arpa.
+        //    HostAddress:2001:0:0:0:0:0:0:1 TTL:10
+        //    TXT:[key1=0102, key2=03] TTL:10
+
+        List<String> lines = executeCommand("dns browse " + serviceType);
+        NsdServiceInfo info = new NsdServiceInfo();
+        info.setServiceName(lines.get(1));
+        info.setServiceType(serviceType);
+        info.setPort(DnsServiceCliOutputParser.parsePort(lines.get(2)));
+        info.setHostname(DnsServiceCliOutputParser.parseHostname(lines.get(3)));
+        info.setHostAddresses(List.of(DnsServiceCliOutputParser.parseHostAddress(lines.get(4))));
+        DnsServiceCliOutputParser.parseTxtIntoServiceInfo(lines.get(5), info);
+
+        return info;
+    }
+
+    /** Returns the resolved service instance. */
+    public NsdServiceInfo resolveService(String serviceName, String serviceType) {
+        // CLI output:
+        // DNS service resolution response for test-service for service
+        // _test._tcp.default.service.arpa.
+        // Port:12345, Priority:0, Weight:0, TTL:10
+        // Host:Android.default.service.arpa.
+        // HostAddress:2001:0:0:0:0:0:0:1 TTL:10
+        // TXT:[key1=0102, key2=03] TTL:10
+
+        List<String> lines = executeCommand("dns service %s %s", serviceName, serviceType);
+        NsdServiceInfo info = new NsdServiceInfo();
+        info.setServiceName(serviceName);
+        info.setServiceType(serviceType);
+        info.setPort(DnsServiceCliOutputParser.parsePort(lines.get(1)));
+        info.setHostname(DnsServiceCliOutputParser.parseHostname(lines.get(2)));
+        info.setHostAddresses(List.of(DnsServiceCliOutputParser.parseHostAddress(lines.get(3))));
+        DnsServiceCliOutputParser.parseTxtIntoServiceInfo(lines.get(4), info);
+
+        return info;
+    }
+
     /** Runs the "factoryreset" command on the device. */
     public void factoryReset() {
         try {
@@ -223,7 +397,36 @@
         executeCommand("ipmaddr add " + address.getHostAddress());
     }
 
-    public void ping(Inet6Address address, Inet6Address source, int size, int count) {
+    public void ping(Inet6Address address, Inet6Address source) {
+        ping(
+                address,
+                source,
+                PING_SIZE,
+                1 /* count */,
+                PING_INTERVAL,
+                HOP_LIMIT,
+                PING_TIMEOUT_SECONDS);
+    }
+
+    public void ping(Inet6Address address) {
+        ping(
+                address,
+                null,
+                PING_SIZE,
+                1 /* count */,
+                PING_INTERVAL,
+                HOP_LIMIT,
+                PING_TIMEOUT_SECONDS);
+    }
+
+    private void ping(
+            Inet6Address address,
+            Inet6Address source,
+            int size,
+            int count,
+            int interval,
+            int hopLimit,
+            float timeout) {
         String cmd =
                 "ping"
                         + ((source == null) ? "" : (" -I " + source.getHostAddress()))
@@ -232,12 +435,19 @@
                         + " "
                         + size
                         + " "
-                        + count;
+                        + count
+                        + " "
+                        + interval
+                        + " "
+                        + hopLimit
+                        + " "
+                        + timeout;
         executeCommand(cmd);
     }
 
-    public void ping(Inet6Address address) {
-        ping(address, null, 100 /* size */, 1 /* count */);
+    @FormatMethod
+    private List<String> executeCommand(String commandFormat, Object... args) {
+        return executeCommand(String.format(commandFormat, args));
     }
 
     private List<String> executeCommand(String command) {
@@ -263,7 +473,7 @@
             if (line.equals("Done")) {
                 break;
             }
-            if (line.startsWith("Error:")) {
+            if (line.startsWith("Error")) {
                 fail("ot-cli-ftd reported an error: " + line);
             }
             if (!line.startsWith("> ")) {
@@ -272,4 +482,68 @@
         }
         return result;
     }
+
+    private static String txtMapToHexString(Map<String, byte[]> txtMap) {
+        if (txtMap == null) {
+            return "";
+        }
+        StringBuilder sb = new StringBuilder();
+        for (Map.Entry<String, byte[]> entry : txtMap.entrySet()) {
+            int length = entry.getKey().length() + entry.getValue().length + 1;
+            sb.append(String.format("%02x", length));
+            sb.append(toHexString(entry.getKey()));
+            sb.append(toHexString("="));
+            sb.append(toHexString(entry.getValue()));
+        }
+        return sb.toString();
+    }
+
+    private static String toHexString(String s) {
+        return toHexString(s.getBytes(StandardCharsets.UTF_8));
+    }
+
+    private static String toHexString(byte[] bytes) {
+        return base16().encode(bytes);
+    }
+
+    private static final class DnsServiceCliOutputParser {
+        /** Returns the first match in the input of a given regex pattern. */
+        private static Matcher firstMatchOf(String input, String regex) {
+            Matcher matcher = Pattern.compile(regex).matcher(input);
+            matcher.find();
+            return matcher;
+        }
+
+        // Example: "Port:12345"
+        private static int parsePort(String line) {
+            return Integer.parseInt(firstMatchOf(line, "Port:(\\d+)").group(1));
+        }
+
+        // Example: "Host:Android.default.service.arpa."
+        private static String parseHostname(String line) {
+            return firstMatchOf(line, "Host:(.+)").group(1);
+        }
+
+        // Example: "HostAddress:2001:0:0:0:0:0:0:1"
+        private static InetAddress parseHostAddress(String line) {
+            return InetAddresses.parseNumericAddress(
+                    firstMatchOf(line, "HostAddress:([^ ]+)").group(1));
+        }
+
+        // Example: "TXT:[key1=0102, key2=03]"
+        private static void parseTxtIntoServiceInfo(String line, NsdServiceInfo serviceInfo) {
+            String txtString = firstMatchOf(line, "TXT:\\[([^\\]]+)\\]").group(1);
+            for (String txtEntry : txtString.split(",")) {
+                String[] nameAndValue = txtEntry.trim().split("=");
+                String name = nameAndValue[0];
+                String value = nameAndValue[1];
+                byte[] bytes = new byte[value.length() / 2];
+                for (int i = 0; i < value.length(); i += 2) {
+                    byte b = (byte) ((value.charAt(i) - '0') << 4 | (value.charAt(i + 1) - '0'));
+                    bytes[i / 2] = b;
+                }
+                serviceInfo.setAttribute(name, bytes);
+            }
+        }
+    }
 }
diff --git a/thread/tests/integration/src/android/net/thread/utils/IntegrationTestUtils.java b/thread/tests/integration/src/android/net/thread/utils/IntegrationTestUtils.java
index 74251a6..2237e65 100644
--- a/thread/tests/integration/src/android/net/thread/utils/IntegrationTestUtils.java
+++ b/thread/tests/integration/src/android/net/thread/utils/IntegrationTestUtils.java
@@ -23,11 +23,16 @@
 
 import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
 
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+
 import android.net.TestNetworkInterface;
+import android.net.nsd.NsdManager;
+import android.net.nsd.NsdServiceInfo;
 import android.net.thread.ThreadNetworkController;
 import android.os.Handler;
 import android.os.SystemClock;
-import android.os.SystemProperties;
+
+import androidx.annotation.NonNull;
 
 import com.android.net.module.util.Struct;
 import com.android.net.module.util.structs.Icmpv6Header;
@@ -51,6 +56,7 @@
 import java.time.Duration;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
@@ -66,15 +72,10 @@
     public static final Duration JOIN_TIMEOUT = Duration.ofSeconds(30);
     public static final Duration LEAVE_TIMEOUT = Duration.ofSeconds(2);
     public static final Duration CALLBACK_TIMEOUT = Duration.ofSeconds(1);
+    public static final Duration SERVICE_DISCOVERY_TIMEOUT = Duration.ofSeconds(20);
 
     private IntegrationTestUtils() {}
 
-    /** Returns whether the device supports simulated Thread radio. */
-    public static boolean isSimulatedThreadRadioSupported() {
-        // The integration test uses SIMULATION Thread radio so that it only supports CuttleFish.
-        return SystemProperties.get("ro.product.model").startsWith("Cuttlefish");
-    }
-
     /**
      * Waits for the given {@link Supplier} to be true until given timeout.
      *
@@ -289,4 +290,106 @@
         }
         return false;
     }
+
+    /** Return the first discovered service of {@code serviceType}. */
+    public static NsdServiceInfo discoverService(NsdManager nsdManager, String serviceType)
+            throws Exception {
+        CompletableFuture<NsdServiceInfo> serviceInfoFuture = new CompletableFuture<>();
+        NsdManager.DiscoveryListener listener =
+                new DefaultDiscoveryListener() {
+                    @Override
+                    public void onServiceFound(NsdServiceInfo serviceInfo) {
+                        serviceInfoFuture.complete(serviceInfo);
+                    }
+                };
+        nsdManager.discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD, listener);
+        try {
+            serviceInfoFuture.get(SERVICE_DISCOVERY_TIMEOUT.toMillis(), MILLISECONDS);
+        } finally {
+            nsdManager.stopServiceDiscovery(listener);
+        }
+
+        return serviceInfoFuture.get();
+    }
+
+    /**
+     * Returns the {@link NsdServiceInfo} when a service instance of {@code serviceType} gets lost.
+     */
+    public static NsdManager.DiscoveryListener discoverForServiceLost(
+            NsdManager nsdManager,
+            String serviceType,
+            CompletableFuture<NsdServiceInfo> serviceInfoFuture) {
+        NsdManager.DiscoveryListener listener =
+                new DefaultDiscoveryListener() {
+                    @Override
+                    public void onServiceLost(NsdServiceInfo serviceInfo) {
+                        serviceInfoFuture.complete(serviceInfo);
+                    }
+                };
+        nsdManager.discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD, listener);
+        return listener;
+    }
+
+    /** Resolves the service. */
+    public static NsdServiceInfo resolveService(NsdManager nsdManager, NsdServiceInfo serviceInfo)
+            throws Exception {
+        return resolveServiceUntil(nsdManager, serviceInfo, s -> true);
+    }
+
+    /** Returns the first resolved service that satisfies the {@code predicate}. */
+    public static NsdServiceInfo resolveServiceUntil(
+            NsdManager nsdManager, NsdServiceInfo serviceInfo, Predicate<NsdServiceInfo> predicate)
+            throws Exception {
+        CompletableFuture<NsdServiceInfo> resolvedServiceInfoFuture = new CompletableFuture<>();
+        NsdManager.ServiceInfoCallback callback =
+                new DefaultServiceInfoCallback() {
+                    @Override
+                    public void onServiceUpdated(@NonNull NsdServiceInfo serviceInfo) {
+                        if (predicate.test(serviceInfo)) {
+                            resolvedServiceInfoFuture.complete(serviceInfo);
+                        }
+                    }
+                };
+        nsdManager.registerServiceInfoCallback(serviceInfo, directExecutor(), callback);
+        try {
+            return resolvedServiceInfoFuture.get(
+                    SERVICE_DISCOVERY_TIMEOUT.toMillis(), MILLISECONDS);
+        } finally {
+            nsdManager.unregisterServiceInfoCallback(callback);
+        }
+    }
+
+    private static class DefaultDiscoveryListener implements NsdManager.DiscoveryListener {
+        @Override
+        public void onStartDiscoveryFailed(String serviceType, int errorCode) {}
+
+        @Override
+        public void onStopDiscoveryFailed(String serviceType, int errorCode) {}
+
+        @Override
+        public void onDiscoveryStarted(String serviceType) {}
+
+        @Override
+        public void onDiscoveryStopped(String serviceType) {}
+
+        @Override
+        public void onServiceFound(NsdServiceInfo serviceInfo) {}
+
+        @Override
+        public void onServiceLost(NsdServiceInfo serviceInfo) {}
+    }
+
+    private static class DefaultServiceInfoCallback implements NsdManager.ServiceInfoCallback {
+        @Override
+        public void onServiceInfoCallbackRegistrationFailed(int errorCode) {}
+
+        @Override
+        public void onServiceUpdated(@NonNull NsdServiceInfo serviceInfo) {}
+
+        @Override
+        public void onServiceLost() {}
+
+        @Override
+        public void onServiceInfoCallbackUnregistered() {}
+    }
 }
diff --git a/thread/tests/integration/src/android/net/thread/utils/OtDaemonController.java b/thread/tests/integration/src/android/net/thread/utils/OtDaemonController.java
index 4a06fe8..f39a064 100644
--- a/thread/tests/integration/src/android/net/thread/utils/OtDaemonController.java
+++ b/thread/tests/integration/src/android/net/thread/utils/OtDaemonController.java
@@ -62,6 +62,24 @@
                 .toList();
     }
 
+    /** Returns {@code true} if the Thread interface is up. */
+    public boolean isInterfaceUp() {
+        String output = executeCommand("ifconfig");
+        return output.contains("up");
+    }
+
+    /** Returns the ML-EID of the device. */
+    public Inet6Address getMlEid() {
+        String addressStr = executeCommand("ipaddr mleid").split("\n")[0].trim();
+        return (Inet6Address) InetAddresses.parseNumericAddress(addressStr);
+    }
+
+    /** Returns the country code on ot-daemon. */
+    public String getCountryCode() {
+        String countryCodeStr = executeCommand("region").split("\n")[0].trim();
+        return countryCodeStr;
+    }
+
     public String executeCommand(String cmd) {
         return SystemUtil.runShellCommand(OT_CTL + " " + cmd);
     }
diff --git a/thread/tests/integration/src/android/net/thread/utils/ThreadNetworkControllerWrapper.java b/thread/tests/integration/src/android/net/thread/utils/ThreadNetworkControllerWrapper.java
new file mode 100644
index 0000000..7e84233
--- /dev/null
+++ b/thread/tests/integration/src/android/net/thread/utils/ThreadNetworkControllerWrapper.java
@@ -0,0 +1,208 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package android.net.thread.utils;
+
+import static android.Manifest.permission.ACCESS_NETWORK_STATE;
+import static android.Manifest.permission.NETWORK_SETTINGS;
+import static android.net.thread.ThreadNetworkManager.PERMISSION_THREAD_NETWORK_PRIVILEGED;
+import static android.net.thread.utils.IntegrationTestUtils.CALLBACK_TIMEOUT;
+
+import static com.android.testutils.TestPermissionUtil.runAsShell;
+
+import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
+
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import android.annotation.Nullable;
+import android.content.Context;
+import android.net.thread.ActiveOperationalDataset;
+import android.net.thread.ThreadNetworkController;
+import android.net.thread.ThreadNetworkController.StateCallback;
+import android.net.thread.ThreadNetworkException;
+import android.net.thread.ThreadNetworkManager;
+import android.os.OutcomeReceiver;
+
+import java.time.Duration;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeoutException;
+
+/** A helper class which provides synchronous API wrappers for {@link ThreadNetworkController}. */
+public final class ThreadNetworkControllerWrapper {
+    public static final Duration JOIN_TIMEOUT = Duration.ofSeconds(10);
+    public static final Duration LEAVE_TIMEOUT = Duration.ofSeconds(2);
+    private static final Duration CALLBACK_TIMEOUT = Duration.ofSeconds(1);
+    private static final Duration SET_ENABLED_TIMEOUT = Duration.ofSeconds(2);
+
+    private final ThreadNetworkController mController;
+
+    /**
+     * Returns a new {@link ThreadNetworkControllerWrapper} instance or {@code null} if Thread
+     * feature is not supported on this device.
+     */
+    @Nullable
+    public static ThreadNetworkControllerWrapper newInstance(Context context) {
+        final ThreadNetworkManager manager = context.getSystemService(ThreadNetworkManager.class);
+        if (manager == null) {
+            return null;
+        }
+        return new ThreadNetworkControllerWrapper(manager.getAllThreadNetworkControllers().get(0));
+    }
+
+    private ThreadNetworkControllerWrapper(ThreadNetworkController controller) {
+        mController = controller;
+    }
+
+    /**
+     * Returns the Thread enabled state.
+     *
+     * <p>The value can be one of {@code ThreadNetworkController#STATE_*}.
+     */
+    public final int getEnabledState()
+            throws InterruptedException, ExecutionException, TimeoutException {
+        CompletableFuture<Integer> future = new CompletableFuture<>();
+        StateCallback callback =
+                new StateCallback() {
+                    @Override
+                    public void onThreadEnableStateChanged(int enabledState) {
+                        future.complete(enabledState);
+                    }
+
+                    @Override
+                    public void onDeviceRoleChanged(int deviceRole) {}
+                };
+
+        runAsShell(
+                ACCESS_NETWORK_STATE,
+                () -> mController.registerStateCallback(directExecutor(), callback));
+        try {
+            return future.get(CALLBACK_TIMEOUT.toSeconds(), SECONDS);
+        } finally {
+            runAsShell(ACCESS_NETWORK_STATE, () -> mController.unregisterStateCallback(callback));
+        }
+    }
+
+    /**
+     * Returns the Thread device role.
+     *
+     * <p>The value can be one of {@code ThreadNetworkController#DEVICE_ROLE_*}.
+     */
+    public final int getDeviceRole()
+            throws InterruptedException, ExecutionException, TimeoutException {
+        CompletableFuture<Integer> future = new CompletableFuture<>();
+        StateCallback callback = future::complete;
+
+        runAsShell(
+                ACCESS_NETWORK_STATE,
+                () -> mController.registerStateCallback(directExecutor(), callback));
+        try {
+            return future.get(CALLBACK_TIMEOUT.toSeconds(), SECONDS);
+        } finally {
+            runAsShell(ACCESS_NETWORK_STATE, () -> mController.unregisterStateCallback(callback));
+        }
+    }
+
+    /** An synchronous variant of {@link ThreadNetworkController#setEnabled}. */
+    public void setEnabledAndWait(boolean enabled)
+            throws InterruptedException, ExecutionException, TimeoutException {
+        CompletableFuture<Void> future = new CompletableFuture<>();
+        runAsShell(
+                PERMISSION_THREAD_NETWORK_PRIVILEGED,
+                () ->
+                        mController.setEnabled(
+                                enabled, directExecutor(), newOutcomeReceiver(future)));
+        future.get(SET_ENABLED_TIMEOUT.toSeconds(), SECONDS);
+    }
+
+    /** Joins the given network and wait for this device to become attached. */
+    public void joinAndWait(ActiveOperationalDataset activeDataset)
+            throws InterruptedException, ExecutionException, TimeoutException {
+        CompletableFuture<Void> future = new CompletableFuture<>();
+        runAsShell(
+                PERMISSION_THREAD_NETWORK_PRIVILEGED,
+                () ->
+                        mController.join(
+                                activeDataset, directExecutor(), newOutcomeReceiver(future)));
+        future.get(JOIN_TIMEOUT.toSeconds(), SECONDS);
+    }
+
+    /** An synchronous variant of {@link ThreadNetworkController#leave}. */
+    public void leaveAndWait() throws InterruptedException, ExecutionException, TimeoutException {
+        CompletableFuture<Void> future = new CompletableFuture<>();
+        runAsShell(
+                PERMISSION_THREAD_NETWORK_PRIVILEGED,
+                () -> mController.leave(directExecutor(), future::complete));
+        future.get(LEAVE_TIMEOUT.toSeconds(), SECONDS);
+    }
+
+    /** Waits for the device role to become {@code deviceRole}. */
+    public int waitForRole(int deviceRole, Duration timeout)
+            throws InterruptedException, ExecutionException, TimeoutException {
+        return waitForRoleAnyOf(List.of(deviceRole), timeout);
+    }
+
+    /** Waits for the device role to become one of the values specified in {@code deviceRoles}. */
+    public int waitForRoleAnyOf(List<Integer> deviceRoles, Duration timeout)
+            throws InterruptedException, ExecutionException, TimeoutException {
+        CompletableFuture<Integer> future = new CompletableFuture<>();
+        ThreadNetworkController.StateCallback callback =
+                newRole -> {
+                    if (deviceRoles.contains(newRole)) {
+                        future.complete(newRole);
+                    }
+                };
+
+        runAsShell(
+                ACCESS_NETWORK_STATE,
+                () -> mController.registerStateCallback(directExecutor(), callback));
+
+        try {
+            return future.get(timeout.toSeconds(), SECONDS);
+        } finally {
+            mController.unregisterStateCallback(callback);
+        }
+    }
+
+    /** An synchronous variant of {@link ThreadNetworkController#setTestNetworkAsUpstream}. */
+    public void setTestNetworkAsUpstreamAndWait(@Nullable String networkInterfaceName)
+            throws InterruptedException, ExecutionException, TimeoutException {
+        CompletableFuture<Void> future = new CompletableFuture<>();
+        runAsShell(
+                PERMISSION_THREAD_NETWORK_PRIVILEGED,
+                NETWORK_SETTINGS,
+                () -> {
+                    mController.setTestNetworkAsUpstream(
+                            networkInterfaceName, directExecutor(), future::complete);
+                });
+        future.get(CALLBACK_TIMEOUT.toSeconds(), SECONDS);
+    }
+
+    private static <V> OutcomeReceiver<V, ThreadNetworkException> newOutcomeReceiver(
+            CompletableFuture<V> future) {
+        return new OutcomeReceiver<V, ThreadNetworkException>() {
+            @Override
+            public void onResult(V result) {
+                future.complete(result);
+            }
+
+            @Override
+            public void onError(ThreadNetworkException e) {
+                future.completeExceptionally(e);
+            }
+        };
+    }
+}
diff --git a/thread/tests/unit/src/android/net/thread/ThreadNetworkControllerTest.java b/thread/tests/unit/src/android/net/thread/ThreadNetworkControllerTest.java
index 75eb043..ac74372 100644
--- a/thread/tests/unit/src/android/net/thread/ThreadNetworkControllerTest.java
+++ b/thread/tests/unit/src/android/net/thread/ThreadNetworkControllerTest.java
@@ -19,6 +19,7 @@
 import static android.net.thread.ThreadNetworkController.DEVICE_ROLE_CHILD;
 import static android.net.thread.ThreadNetworkException.ERROR_UNAVAILABLE;
 import static android.net.thread.ThreadNetworkException.ERROR_UNSUPPORTED_CHANNEL;
+import static android.net.thread.ThreadNetworkException.ERROR_UNSUPPORTED_OPERATION;
 import static android.os.Process.SYSTEM_UID;
 
 import static com.google.common.io.BaseEncoding.base16;
@@ -33,6 +34,7 @@
 import android.os.Binder;
 import android.os.OutcomeReceiver;
 import android.os.Process;
+import android.util.SparseIntArray;
 
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.filters.SmallTest;
@@ -77,6 +79,13 @@
     private static final ActiveOperationalDataset DEFAULT_DATASET =
             ActiveOperationalDataset.fromThreadTlvs(DEFAULT_DATASET_TLVS);
 
+    private static final SparseIntArray DEFAULT_CHANNEL_POWERS =
+            new SparseIntArray() {
+                {
+                    put(20, 32767);
+                }
+            };
+
     @Before
     public void setUp() {
         MockitoAnnotations.initMocks(this);
@@ -111,6 +120,10 @@
         return (IOperationReceiver) invocation.getArguments()[1];
     }
 
+    private static IOperationReceiver getSetChannelMaxPowersReceiver(InvocationOnMock invocation) {
+        return (IOperationReceiver) invocation.getArguments()[1];
+    }
+
     private static IActiveOperationalDatasetReceiver getCreateDatasetReceiver(
             InvocationOnMock invocation) {
         return (IActiveOperationalDatasetReceiver) invocation.getArguments()[1];
@@ -361,6 +374,51 @@
     }
 
     @Test
+    public void setChannelMaxPowers_callbackIsInvokedWithCallingAppIdentity() throws Exception {
+        setBinderUid(SYSTEM_UID);
+
+        AtomicInteger successCallbackUid = new AtomicInteger(0);
+        AtomicInteger errorCallbackUid = new AtomicInteger(0);
+
+        doAnswer(
+                        invoke -> {
+                            getSetChannelMaxPowersReceiver(invoke).onSuccess();
+                            return null;
+                        })
+                .when(mMockService)
+                .setChannelMaxPowers(any(ChannelMaxPower[].class), any(IOperationReceiver.class));
+        mController.setChannelMaxPowers(
+                DEFAULT_CHANNEL_POWERS,
+                Runnable::run,
+                v -> successCallbackUid.set(Binder.getCallingUid()));
+        doAnswer(
+                        invoke -> {
+                            getSetChannelMaxPowersReceiver(invoke)
+                                    .onError(ERROR_UNSUPPORTED_OPERATION, "");
+                            return null;
+                        })
+                .when(mMockService)
+                .setChannelMaxPowers(any(ChannelMaxPower[].class), any(IOperationReceiver.class));
+        mController.setChannelMaxPowers(
+                DEFAULT_CHANNEL_POWERS,
+                Runnable::run,
+                new OutcomeReceiver<>() {
+                    @Override
+                    public void onResult(Void unused) {}
+
+                    @Override
+                    public void onError(ThreadNetworkException e) {
+                        errorCallbackUid.set(Binder.getCallingUid());
+                    }
+                });
+
+        assertThat(successCallbackUid.get()).isNotEqualTo(SYSTEM_UID);
+        assertThat(successCallbackUid.get()).isEqualTo(Process.myUid());
+        assertThat(errorCallbackUid.get()).isNotEqualTo(SYSTEM_UID);
+        assertThat(errorCallbackUid.get()).isEqualTo(Process.myUid());
+    }
+
+    @Test
     public void setTestNetworkAsUpstream_callbackIsInvokedWithCallingAppIdentity()
             throws Exception {
         setBinderUid(SYSTEM_UID);
diff --git a/thread/tests/unit/src/android/net/thread/ThreadNetworkExceptionTest.java b/thread/tests/unit/src/android/net/thread/ThreadNetworkExceptionTest.java
index f62b437..5908c20 100644
--- a/thread/tests/unit/src/android/net/thread/ThreadNetworkExceptionTest.java
+++ b/thread/tests/unit/src/android/net/thread/ThreadNetworkExceptionTest.java
@@ -32,6 +32,6 @@
     public void constructor_tooLargeErrorCode_throwsIllegalArgumentException() throws Exception {
         // TODO (b/323791003): move this test case to cts/ThreadNetworkExceptionTest when mainline
         // CTS is ready.
-        assertThrows(IllegalArgumentException.class, () -> new ThreadNetworkException(13, "13"));
+        assertThrows(IllegalArgumentException.class, () -> new ThreadNetworkException(14, "14"));
     }
 }
diff --git a/thread/tests/unit/src/android/net/thread/ThreadPersistentSettingsTest.java b/thread/tests/unit/src/android/net/thread/ThreadPersistentSettingsTest.java
deleted file mode 100644
index 11aabb8..0000000
--- a/thread/tests/unit/src/android/net/thread/ThreadPersistentSettingsTest.java
+++ /dev/null
@@ -1,127 +0,0 @@
-/*
- * Copyright (C) 2024 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.android.server.thread;
-
-import static com.android.server.thread.ThreadPersistentSettings.THREAD_ENABLED;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import static org.mockito.Mockito.any;
-import static org.mockito.Mockito.anyInt;
-import static org.mockito.Mockito.doAnswer;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.never;
-import static org.mockito.Mockito.validateMockitoUsage;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
-
-import android.os.PersistableBundle;
-import android.test.suitebuilder.annotation.SmallTest;
-import android.util.AtomicFile;
-
-import androidx.test.runner.AndroidJUnit4;
-
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-import org.mockito.Mock;
-import org.mockito.MockitoAnnotations;
-
-import java.io.ByteArrayOutputStream;
-import java.io.FileInputStream;
-import java.io.FileOutputStream;
-
-/** Unit tests for {@link ThreadPersistentSettings}. */
-@RunWith(AndroidJUnit4.class)
-@SmallTest
-public class ThreadPersistentSettingsTest {
-    @Mock private AtomicFile mAtomicFile;
-
-    private ThreadPersistentSettings mThreadPersistentSetting;
-
-    @Before
-    public void setUp() throws Exception {
-        MockitoAnnotations.initMocks(this);
-
-        FileOutputStream fos = mock(FileOutputStream.class);
-        when(mAtomicFile.startWrite()).thenReturn(fos);
-        mThreadPersistentSetting = new ThreadPersistentSettings(mAtomicFile);
-    }
-
-    /** Called after each test */
-    @After
-    public void tearDown() {
-        validateMockitoUsage();
-    }
-
-    @Test
-    public void put_ThreadFeatureEnabledTrue_returnsTrue() throws Exception {
-        mThreadPersistentSetting.put(THREAD_ENABLED.key, true);
-
-        assertThat(mThreadPersistentSetting.get(THREAD_ENABLED)).isTrue();
-        // Confirm that file writes have been triggered.
-        verify(mAtomicFile).startWrite();
-        verify(mAtomicFile).finishWrite(any());
-    }
-
-    @Test
-    public void put_ThreadFeatureEnabledFalse_returnsFalse() throws Exception {
-        mThreadPersistentSetting.put(THREAD_ENABLED.key, false);
-
-        assertThat(mThreadPersistentSetting.get(THREAD_ENABLED)).isFalse();
-        // Confirm that file writes have been triggered.
-        verify(mAtomicFile).startWrite();
-        verify(mAtomicFile).finishWrite(any());
-    }
-
-    @Test
-    public void initialize_readsFromFile() throws Exception {
-        byte[] data = createXmlForParsing(THREAD_ENABLED.key, false);
-        setupAtomicFileMockForRead(data);
-
-        // Trigger file read.
-        mThreadPersistentSetting.initialize();
-
-        assertThat(mThreadPersistentSetting.get(THREAD_ENABLED)).isFalse();
-        verify(mAtomicFile, never()).startWrite();
-    }
-
-    private byte[] createXmlForParsing(String key, Boolean value) throws Exception {
-        PersistableBundle bundle = new PersistableBundle();
-        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
-        bundle.putBoolean(key, value);
-        bundle.writeToStream(outputStream);
-        return outputStream.toByteArray();
-    }
-
-    private void setupAtomicFileMockForRead(byte[] dataToRead) throws Exception {
-        FileInputStream is = mock(FileInputStream.class);
-        when(mAtomicFile.openRead()).thenReturn(is);
-        when(is.available()).thenReturn(dataToRead.length).thenReturn(0);
-        doAnswer(
-                        invocation -> {
-                            byte[] data = invocation.getArgument(0);
-                            int pos = invocation.getArgument(1);
-                            if (pos == dataToRead.length) return 0; // read complete.
-                            System.arraycopy(dataToRead, 0, data, 0, dataToRead.length);
-                            return dataToRead.length;
-                        })
-                .when(is)
-                .read(any(), anyInt(), anyInt());
-    }
-}
diff --git a/thread/tests/unit/src/com/android/server/thread/NsdPublisherTest.java b/thread/tests/unit/src/com/android/server/thread/NsdPublisherTest.java
index 8aea0a3..8886c73 100644
--- a/thread/tests/unit/src/com/android/server/thread/NsdPublisherTest.java
+++ b/thread/tests/unit/src/com/android/server/thread/NsdPublisherTest.java
@@ -23,27 +23,38 @@
 
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.argThat;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.spy;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 
+import android.net.InetAddresses;
+import android.net.nsd.DiscoveryRequest;
 import android.net.nsd.NsdManager;
 import android.net.nsd.NsdServiceInfo;
 import android.os.Handler;
 import android.os.test.TestLooper;
 
 import com.android.server.thread.openthread.DnsTxtAttribute;
+import com.android.server.thread.openthread.INsdDiscoverServiceCallback;
+import com.android.server.thread.openthread.INsdResolveServiceCallback;
 import com.android.server.thread.openthread.INsdStatusReceiver;
 
 import org.junit.Before;
 import org.junit.Test;
 import org.mockito.ArgumentCaptor;
+import org.mockito.ArgumentMatcher;
 import org.mockito.Mock;
 import org.mockito.MockitoAnnotations;
 
+import java.net.InetAddress;
+import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
+import java.util.Objects;
 import java.util.Set;
 import java.util.concurrent.Executor;
 
@@ -53,6 +64,8 @@
 
     @Mock private INsdStatusReceiver mRegistrationReceiver;
     @Mock private INsdStatusReceiver mUnregistrationReceiver;
+    @Mock private INsdDiscoverServiceCallback mDiscoverServiceCallback;
+    @Mock private INsdResolveServiceCallback mResolveServiceCallback;
 
     private TestLooper mTestLooper;
     private NsdPublisher mNsdPublisher;
@@ -282,7 +295,349 @@
     }
 
     @Test
-    public void onOtDaemonDied_unregisterAll() {
+    public void registerHost_nsdManagerSucceeds_serviceRegistrationSucceeds() throws Exception {
+        prepareTest();
+
+        mNsdPublisher.registerHost(
+                "MyHost",
+                List.of("2001:db8::1", "2001:db8::2", "2001:db8::3"),
+                mRegistrationReceiver,
+                16 /* listenerId */);
+
+        mTestLooper.dispatchAll();
+
+        ArgumentCaptor<NsdServiceInfo> actualServiceInfoCaptor =
+                ArgumentCaptor.forClass(NsdServiceInfo.class);
+        ArgumentCaptor<NsdManager.RegistrationListener> actualRegistrationListenerCaptor =
+                ArgumentCaptor.forClass(NsdManager.RegistrationListener.class);
+
+        verify(mMockNsdManager, times(1))
+                .registerService(
+                        actualServiceInfoCaptor.capture(),
+                        eq(PROTOCOL_DNS_SD),
+                        any(),
+                        actualRegistrationListenerCaptor.capture());
+
+        NsdServiceInfo actualServiceInfo = actualServiceInfoCaptor.getValue();
+        NsdManager.RegistrationListener actualRegistrationListener =
+                actualRegistrationListenerCaptor.getValue();
+
+        actualRegistrationListener.onServiceRegistered(actualServiceInfo);
+        mTestLooper.dispatchAll();
+
+        assertThat(actualServiceInfo.getServiceName()).isNull();
+        assertThat(actualServiceInfo.getServiceType()).isNull();
+        assertThat(actualServiceInfo.getSubtypes()).isEmpty();
+        assertThat(actualServiceInfo.getPort()).isEqualTo(0);
+        assertThat(actualServiceInfo.getAttributes()).isEmpty();
+        assertThat(actualServiceInfo.getHostname()).isEqualTo("MyHost");
+        assertThat(actualServiceInfo.getHostAddresses())
+                .isEqualTo(makeAddresses("2001:db8::1", "2001:db8::2", "2001:db8::3"));
+
+        verify(mRegistrationReceiver, times(1)).onSuccess();
+    }
+
+    @Test
+    public void registerHost_nsdManagerFails_serviceRegistrationFails() throws Exception {
+        prepareTest();
+
+        mNsdPublisher.registerHost(
+                "MyHost",
+                List.of("2001:db8::1", "2001:db8::2", "2001:db8::3"),
+                mRegistrationReceiver,
+                16 /* listenerId */);
+
+        mTestLooper.dispatchAll();
+
+        ArgumentCaptor<NsdServiceInfo> actualServiceInfoCaptor =
+                ArgumentCaptor.forClass(NsdServiceInfo.class);
+        ArgumentCaptor<NsdManager.RegistrationListener> actualRegistrationListenerCaptor =
+                ArgumentCaptor.forClass(NsdManager.RegistrationListener.class);
+
+        verify(mMockNsdManager, times(1))
+                .registerService(
+                        actualServiceInfoCaptor.capture(),
+                        eq(PROTOCOL_DNS_SD),
+                        any(),
+                        actualRegistrationListenerCaptor.capture());
+        mTestLooper.dispatchAll();
+
+        NsdServiceInfo actualServiceInfo = actualServiceInfoCaptor.getValue();
+        NsdManager.RegistrationListener actualRegistrationListener =
+                actualRegistrationListenerCaptor.getValue();
+
+        actualRegistrationListener.onRegistrationFailed(actualServiceInfo, FAILURE_INTERNAL_ERROR);
+        mTestLooper.dispatchAll();
+
+        assertThat(actualServiceInfo.getServiceName()).isNull();
+        assertThat(actualServiceInfo.getServiceType()).isNull();
+        assertThat(actualServiceInfo.getSubtypes()).isEmpty();
+        assertThat(actualServiceInfo.getPort()).isEqualTo(0);
+        assertThat(actualServiceInfo.getAttributes()).isEmpty();
+        assertThat(actualServiceInfo.getHostname()).isEqualTo("MyHost");
+        assertThat(actualServiceInfo.getHostAddresses())
+                .isEqualTo(makeAddresses("2001:db8::1", "2001:db8::2", "2001:db8::3"));
+
+        verify(mRegistrationReceiver, times(1)).onError(FAILURE_INTERNAL_ERROR);
+    }
+
+    @Test
+    public void registerHost_nsdManagerThrows_serviceRegistrationFails() throws Exception {
+        prepareTest();
+
+        doThrow(new IllegalArgumentException("NsdManager fails"))
+                .when(mMockNsdManager)
+                .registerService(any(), anyInt(), any(Executor.class), any());
+
+        mNsdPublisher.registerHost(
+                "MyHost",
+                List.of("2001:db8::1", "2001:db8::2", "2001:db8::3"),
+                mRegistrationReceiver,
+                16 /* listenerId */);
+
+        mTestLooper.dispatchAll();
+
+        verify(mRegistrationReceiver, times(1)).onError(FAILURE_INTERNAL_ERROR);
+    }
+
+    @Test
+    public void unregisterHost_nsdManagerSucceeds_serviceUnregistrationSucceeds() throws Exception {
+        prepareTest();
+
+        mNsdPublisher.registerHost(
+                "MyHost",
+                List.of("2001:db8::1", "2001:db8::2", "2001:db8::3"),
+                mRegistrationReceiver,
+                16 /* listenerId */);
+
+        mTestLooper.dispatchAll();
+
+        ArgumentCaptor<NsdServiceInfo> actualServiceInfoCaptor =
+                ArgumentCaptor.forClass(NsdServiceInfo.class);
+        ArgumentCaptor<NsdManager.RegistrationListener> actualRegistrationListenerCaptor =
+                ArgumentCaptor.forClass(NsdManager.RegistrationListener.class);
+
+        verify(mMockNsdManager, times(1))
+                .registerService(
+                        actualServiceInfoCaptor.capture(),
+                        eq(PROTOCOL_DNS_SD),
+                        any(Executor.class),
+                        actualRegistrationListenerCaptor.capture());
+
+        NsdServiceInfo actualServiceInfo = actualServiceInfoCaptor.getValue();
+        NsdManager.RegistrationListener actualRegistrationListener =
+                actualRegistrationListenerCaptor.getValue();
+
+        actualRegistrationListener.onServiceRegistered(actualServiceInfo);
+        mNsdPublisher.unregister(mUnregistrationReceiver, 16 /* listenerId */);
+        mTestLooper.dispatchAll();
+        verify(mMockNsdManager, times(1)).unregisterService(actualRegistrationListener);
+
+        actualRegistrationListener.onServiceUnregistered(actualServiceInfo);
+        mTestLooper.dispatchAll();
+        verify(mUnregistrationReceiver, times(1)).onSuccess();
+    }
+
+    @Test
+    public void unregisterHost_nsdManagerFails_serviceUnregistrationFails() throws Exception {
+        prepareTest();
+
+        mNsdPublisher.registerHost(
+                "MyHost",
+                List.of("2001:db8::1", "2001:db8::2", "2001:db8::3"),
+                mRegistrationReceiver,
+                16 /* listenerId */);
+
+        mTestLooper.dispatchAll();
+
+        ArgumentCaptor<NsdServiceInfo> actualServiceInfoCaptor =
+                ArgumentCaptor.forClass(NsdServiceInfo.class);
+        ArgumentCaptor<NsdManager.RegistrationListener> actualRegistrationListenerCaptor =
+                ArgumentCaptor.forClass(NsdManager.RegistrationListener.class);
+
+        verify(mMockNsdManager, times(1))
+                .registerService(
+                        actualServiceInfoCaptor.capture(),
+                        eq(PROTOCOL_DNS_SD),
+                        any(Executor.class),
+                        actualRegistrationListenerCaptor.capture());
+
+        NsdServiceInfo actualServiceInfo = actualServiceInfoCaptor.getValue();
+        NsdManager.RegistrationListener actualRegistrationListener =
+                actualRegistrationListenerCaptor.getValue();
+
+        actualRegistrationListener.onServiceRegistered(actualServiceInfo);
+        mNsdPublisher.unregister(mUnregistrationReceiver, 16 /* listenerId */);
+        mTestLooper.dispatchAll();
+        verify(mMockNsdManager, times(1)).unregisterService(actualRegistrationListener);
+
+        actualRegistrationListener.onUnregistrationFailed(
+                actualServiceInfo, FAILURE_INTERNAL_ERROR);
+        mTestLooper.dispatchAll();
+        verify(mUnregistrationReceiver, times(1)).onError(0);
+    }
+
+    @Test
+    public void discoverService_serviceDiscovered() throws Exception {
+        prepareTest();
+
+        mNsdPublisher.discoverService("_test._tcp", mDiscoverServiceCallback, 10 /* listenerId */);
+        mTestLooper.dispatchAll();
+        ArgumentCaptor<NsdManager.DiscoveryListener> discoveryListenerArgumentCaptor =
+                ArgumentCaptor.forClass(NsdManager.DiscoveryListener.class);
+        verify(mMockNsdManager, times(1))
+                .discoverServices(
+                        eq(new DiscoveryRequest.Builder(PROTOCOL_DNS_SD, "_test._tcp").build()),
+                        any(Executor.class),
+                        discoveryListenerArgumentCaptor.capture());
+        NsdManager.DiscoveryListener actualDiscoveryListener =
+                discoveryListenerArgumentCaptor.getValue();
+        NsdServiceInfo serviceInfo = new NsdServiceInfo();
+        serviceInfo.setServiceName("test");
+        serviceInfo.setServiceType(null);
+        actualDiscoveryListener.onServiceFound(serviceInfo);
+        mTestLooper.dispatchAll();
+
+        verify(mDiscoverServiceCallback, times(1))
+                .onServiceDiscovered("test", "_test._tcp", true /* isFound */);
+    }
+
+    @Test
+    public void discoverService_serviceLost() throws Exception {
+        prepareTest();
+
+        mNsdPublisher.discoverService("_test._tcp", mDiscoverServiceCallback, 10 /* listenerId */);
+        mTestLooper.dispatchAll();
+        ArgumentCaptor<NsdManager.DiscoveryListener> discoveryListenerArgumentCaptor =
+                ArgumentCaptor.forClass(NsdManager.DiscoveryListener.class);
+        verify(mMockNsdManager, times(1))
+                .discoverServices(
+                        eq(new DiscoveryRequest.Builder(PROTOCOL_DNS_SD, "_test._tcp").build()),
+                        any(Executor.class),
+                        discoveryListenerArgumentCaptor.capture());
+        NsdManager.DiscoveryListener actualDiscoveryListener =
+                discoveryListenerArgumentCaptor.getValue();
+        NsdServiceInfo serviceInfo = new NsdServiceInfo();
+        serviceInfo.setServiceName("test");
+        serviceInfo.setServiceType(null);
+        actualDiscoveryListener.onServiceLost(serviceInfo);
+        mTestLooper.dispatchAll();
+
+        verify(mDiscoverServiceCallback, times(1))
+                .onServiceDiscovered("test", "_test._tcp", false /* isFound */);
+    }
+
+    @Test
+    public void stopServiceDiscovery() {
+        prepareTest();
+
+        mNsdPublisher.discoverService("_test._tcp", mDiscoverServiceCallback, 10 /* listenerId */);
+        mTestLooper.dispatchAll();
+        ArgumentCaptor<NsdManager.DiscoveryListener> discoveryListenerArgumentCaptor =
+                ArgumentCaptor.forClass(NsdManager.DiscoveryListener.class);
+        verify(mMockNsdManager, times(1))
+                .discoverServices(
+                        eq(new DiscoveryRequest.Builder(PROTOCOL_DNS_SD, "_test._tcp").build()),
+                        any(Executor.class),
+                        discoveryListenerArgumentCaptor.capture());
+        NsdManager.DiscoveryListener actualDiscoveryListener =
+                discoveryListenerArgumentCaptor.getValue();
+        NsdServiceInfo serviceInfo = new NsdServiceInfo();
+        serviceInfo.setServiceName("test");
+        serviceInfo.setServiceType(null);
+        actualDiscoveryListener.onServiceFound(serviceInfo);
+        mNsdPublisher.stopServiceDiscovery(10 /* listenerId */);
+        mTestLooper.dispatchAll();
+
+        verify(mMockNsdManager, times(1)).stopServiceDiscovery(actualDiscoveryListener);
+    }
+
+    @Test
+    public void resolveService_serviceResolved() throws Exception {
+        prepareTest();
+
+        mNsdPublisher.resolveService(
+                "test", "_test._tcp", mResolveServiceCallback, 10 /* listenerId */);
+        mTestLooper.dispatchAll();
+        ArgumentCaptor<NsdServiceInfo> serviceInfoArgumentCaptor =
+                ArgumentCaptor.forClass(NsdServiceInfo.class);
+        ArgumentCaptor<NsdManager.ServiceInfoCallback> serviceInfoCallbackArgumentCaptor =
+                ArgumentCaptor.forClass(NsdManager.ServiceInfoCallback.class);
+        verify(mMockNsdManager, times(1))
+                .registerServiceInfoCallback(
+                        serviceInfoArgumentCaptor.capture(),
+                        any(Executor.class),
+                        serviceInfoCallbackArgumentCaptor.capture());
+        assertThat(serviceInfoArgumentCaptor.getValue().getServiceName()).isEqualTo("test");
+        assertThat(serviceInfoArgumentCaptor.getValue().getServiceType()).isEqualTo("_test._tcp");
+        NsdServiceInfo serviceInfo = new NsdServiceInfo();
+        serviceInfo.setServiceName("test");
+        serviceInfo.setServiceType("_test._tcp");
+        serviceInfo.setPort(12345);
+        serviceInfo.setHostname("test-host");
+        serviceInfo.setHostAddresses(
+                List.of(
+                        InetAddress.parseNumericAddress("2001::1"),
+                        InetAddress.parseNumericAddress("2001::2")));
+        serviceInfo.setAttribute("key1", new byte[] {(byte) 0x01, (byte) 0x02});
+        serviceInfo.setAttribute("key2", new byte[] {(byte) 0x03});
+        serviceInfoCallbackArgumentCaptor.getValue().onServiceUpdated(serviceInfo);
+        mTestLooper.dispatchAll();
+
+        verify(mResolveServiceCallback, times(1))
+                .onServiceResolved(
+                        eq("test-host"),
+                        eq("test"),
+                        eq("_test._tcp"),
+                        eq(12345),
+                        eq(List.of("2001::1", "2001::2")),
+                        argThat(
+                                new TxtMatcher(
+                                        List.of(
+                                                makeTxtAttribute("key1", List.of(0x01, 0x02)),
+                                                makeTxtAttribute("key2", List.of(0x03))))),
+                        anyInt());
+    }
+
+    @Test
+    public void stopServiceResolution() throws Exception {
+        prepareTest();
+
+        mNsdPublisher.resolveService(
+                "test", "_test._tcp", mResolveServiceCallback, 10 /* listenerId */);
+        mTestLooper.dispatchAll();
+        ArgumentCaptor<NsdServiceInfo> serviceInfoArgumentCaptor =
+                ArgumentCaptor.forClass(NsdServiceInfo.class);
+        ArgumentCaptor<NsdManager.ServiceInfoCallback> serviceInfoCallbackArgumentCaptor =
+                ArgumentCaptor.forClass(NsdManager.ServiceInfoCallback.class);
+        verify(mMockNsdManager, times(1))
+                .registerServiceInfoCallback(
+                        serviceInfoArgumentCaptor.capture(),
+                        any(Executor.class),
+                        serviceInfoCallbackArgumentCaptor.capture());
+        assertThat(serviceInfoArgumentCaptor.getValue().getServiceName()).isEqualTo("test");
+        assertThat(serviceInfoArgumentCaptor.getValue().getServiceType()).isEqualTo("_test._tcp");
+        NsdServiceInfo serviceInfo = new NsdServiceInfo();
+        serviceInfo.setServiceName("test");
+        serviceInfo.setServiceType("_test._tcp");
+        serviceInfo.setPort(12345);
+        serviceInfo.setHostname("test-host");
+        serviceInfo.setHostAddresses(
+                List.of(
+                        InetAddress.parseNumericAddress("2001::1"),
+                        InetAddress.parseNumericAddress("2001::2")));
+        serviceInfo.setAttribute("key1", new byte[] {(byte) 0x01, (byte) 0x02});
+        serviceInfo.setAttribute("key2", new byte[] {(byte) 0x03});
+        serviceInfoCallbackArgumentCaptor.getValue().onServiceUpdated(serviceInfo);
+        mNsdPublisher.stopServiceResolution(10 /* listenerId */);
+        mTestLooper.dispatchAll();
+
+        verify(mMockNsdManager, times(1))
+                .unregisterServiceInfoCallback(serviceInfoCallbackArgumentCaptor.getValue());
+    }
+
+    @Test
+    public void reset_unregisterAll() {
         prepareTest();
 
         DnsTxtAttribute txt1 = makeTxtAttribute("key1", List.of(0x01, 0x02));
@@ -336,11 +691,41 @@
                 actualRegistrationListenerCaptor.getAllValues().get(1);
         actualListener2.onServiceRegistered(actualServiceInfoCaptor.getValue());
 
-        mNsdPublisher.onOtDaemonDied();
+        mNsdPublisher.registerHost(
+                "Myhost",
+                List.of("2001:db8::1", "2001:db8::2", "2001:db8::3"),
+                mRegistrationReceiver,
+                18 /* listenerId */);
+
+        mTestLooper.dispatchAll();
+
+        verify(mMockNsdManager, times(3))
+                .registerService(
+                        actualServiceInfoCaptor.capture(),
+                        eq(PROTOCOL_DNS_SD),
+                        any(Executor.class),
+                        actualRegistrationListenerCaptor.capture());
+        NsdManager.RegistrationListener actualListener3 =
+                actualRegistrationListenerCaptor.getAllValues().get(1);
+        actualListener3.onServiceRegistered(actualServiceInfoCaptor.getValue());
+
+        mNsdPublisher.reset();
         mTestLooper.dispatchAll();
 
         verify(mMockNsdManager, times(1)).unregisterService(actualListener1);
         verify(mMockNsdManager, times(1)).unregisterService(actualListener2);
+        verify(mMockNsdManager, times(1)).unregisterService(actualListener3);
+    }
+
+    @Test
+    public void onOtDaemonDied_resetIsCalled() {
+        prepareTest();
+        NsdPublisher spyNsdPublisher = spy(mNsdPublisher);
+
+        spyNsdPublisher.onOtDaemonDied();
+        mTestLooper.dispatchAll();
+
+        verify(spyNsdPublisher, times(1)).reset();
     }
 
     private static DnsTxtAttribute makeTxtAttribute(String name, List<Integer> value) {
@@ -356,6 +741,39 @@
         return txtAttribute;
     }
 
+    private static List<InetAddress> makeAddresses(String... addressStrings) {
+        List<InetAddress> addresses = new ArrayList<>();
+
+        for (String addressString : addressStrings) {
+            addresses.add(InetAddresses.parseNumericAddress(addressString));
+        }
+        return addresses;
+    }
+
+    private static class TxtMatcher implements ArgumentMatcher<List<DnsTxtAttribute>> {
+        private final List<DnsTxtAttribute> mAttributes;
+
+        TxtMatcher(List<DnsTxtAttribute> attributes) {
+            mAttributes = attributes;
+        }
+
+        @Override
+        public boolean matches(List<DnsTxtAttribute> argument) {
+            if (argument.size() != mAttributes.size()) {
+                return false;
+            }
+            for (int i = 0; i < argument.size(); ++i) {
+                if (!Objects.equals(argument.get(i).name, mAttributes.get(i).name)) {
+                    return false;
+                }
+                if (!Arrays.equals(argument.get(i).value, mAttributes.get(i).value)) {
+                    return false;
+                }
+            }
+            return true;
+        }
+    }
+
     // @Before and @Test run in different threads. NsdPublisher requires the jobs are run on the
     // thread looper, so TestLooper needs to be created inside each test case to install the
     // correct looper.
diff --git a/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java
index 4948c22..85b6873 100644
--- a/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java
+++ b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java
@@ -16,15 +16,16 @@
 
 package com.android.server.thread;
 
-import static android.Manifest.permission.ACCESS_NETWORK_STATE;
 import static android.net.thread.ActiveOperationalDataset.CHANNEL_PAGE_24_GHZ;
 import static android.net.thread.ThreadNetworkController.STATE_DISABLED;
 import static android.net.thread.ThreadNetworkController.STATE_ENABLED;
 import static android.net.thread.ThreadNetworkException.ERROR_FAILED_PRECONDITION;
 import static android.net.thread.ThreadNetworkException.ERROR_INTERNAL_ERROR;
+import static android.net.thread.ThreadNetworkException.ERROR_THREAD_DISABLED;
 import static android.net.thread.ThreadNetworkManager.DISALLOW_THREAD_NETWORK;
 import static android.net.thread.ThreadNetworkManager.PERMISSION_THREAD_NETWORK_PRIVILEGED;
 
+import static com.android.server.thread.ThreadNetworkCountryCode.DEFAULT_COUNTRY_CODE;
 import static com.android.server.thread.openthread.IOtDaemon.ErrorCode.OT_ERROR_INVALID_STATE;
 
 import static com.google.common.io.BaseEncoding.base16;
@@ -38,6 +39,7 @@
 import static org.mockito.Mockito.any;
 import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.spy;
@@ -48,6 +50,7 @@
 import android.content.BroadcastReceiver;
 import android.content.Context;
 import android.content.Intent;
+import android.content.res.Resources;
 import android.net.ConnectivityManager;
 import android.net.NetworkAgent;
 import android.net.NetworkProvider;
@@ -66,6 +69,9 @@
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.filters.SmallTest;
 
+import com.android.connectivity.resources.R;
+import com.android.server.connectivity.ConnectivityResources;
+import com.android.server.thread.openthread.MeshcopTxtAttributes;
 import com.android.server.thread.openthread.testing.FakeOtDaemon;
 
 import org.junit.Before;
@@ -73,7 +79,9 @@
 import org.junit.runner.RunWith;
 import org.mockito.ArgumentCaptor;
 import org.mockito.Captor;
+import org.mockito.InOrder;
 import org.mockito.Mock;
+import org.mockito.Mockito;
 import org.mockito.MockitoAnnotations;
 
 import java.util.concurrent.CompletableFuture;
@@ -111,6 +119,11 @@
     private static final int DEFAULT_SELECTED_CHANNEL = 11;
     private static final byte[] DEFAULT_SUPPORTED_CHANNEL_MASK_ARRAY = base16().decode("001FFFE0");
 
+    private static final String TEST_VENDOR_OUI = "AC-DE-48";
+    private static final byte[] TEST_VENDOR_OUI_BYTES = new byte[] {(byte) 0xAC, (byte) 0xDE, 0x48};
+    private static final String TEST_VENDOR_NAME = "test vendor";
+    private static final String TEST_MODEL_NAME = "test model";
+
     @Mock private ConnectivityManager mMockConnectivityManager;
     @Mock private NetworkAgent mMockNetworkAgent;
     @Mock private TunInterfaceController mMockTunIfController;
@@ -120,6 +133,9 @@
     @Mock private NsdPublisher mMockNsdPublisher;
     @Mock private UserManager mMockUserManager;
     @Mock private IBinder mIBinder;
+    @Mock Resources mResources;
+    @Mock ConnectivityResources mConnectivityResources;
+
     private Context mContext;
     private TestLooper mTestLooper;
     private FakeOtDaemon mFakeOtDaemon;
@@ -147,6 +163,14 @@
         when(mMockPersistentSettings.get(any())).thenReturn(true);
         when(mMockUserManager.hasUserRestriction(eq(DISALLOW_THREAD_NETWORK))).thenReturn(false);
 
+        when(mConnectivityResources.get()).thenReturn(mResources);
+        when(mResources.getString(eq(R.string.config_thread_vendor_name)))
+                .thenReturn(TEST_VENDOR_NAME);
+        when(mResources.getString(eq(R.string.config_thread_vendor_oui)))
+                .thenReturn(TEST_VENDOR_OUI);
+        when(mResources.getString(eq(R.string.config_thread_model_name)))
+                .thenReturn(TEST_MODEL_NAME);
+
         mService =
                 new ThreadNetworkControllerService(
                         mContext,
@@ -158,7 +182,9 @@
                         mMockInfraIfController,
                         mMockPersistentSettings,
                         mMockNsdPublisher,
-                        mMockUserManager);
+                        mMockUserManager,
+                        mConnectivityResources,
+                        () -> DEFAULT_COUNTRY_CODE);
         mService.setTestNetworkAgent(mMockNetworkAgent);
     }
 
@@ -175,6 +201,93 @@
     }
 
     @Test
+    public void initialize_vendorAndModelNameInResourcesAreSetToOtDaemon() throws Exception {
+        when(mResources.getString(eq(R.string.config_thread_vendor_name)))
+                .thenReturn(TEST_VENDOR_NAME);
+        when(mResources.getString(eq(R.string.config_thread_vendor_oui)))
+                .thenReturn(TEST_VENDOR_OUI);
+        when(mResources.getString(eq(R.string.config_thread_model_name)))
+                .thenReturn(TEST_MODEL_NAME);
+
+        mService.initialize();
+        mTestLooper.dispatchAll();
+
+        MeshcopTxtAttributes meshcopTxts = mFakeOtDaemon.getOverriddenMeshcopTxtAttributes();
+        assertThat(meshcopTxts.vendorName).isEqualTo(TEST_VENDOR_NAME);
+        assertThat(meshcopTxts.vendorOui).isEqualTo(TEST_VENDOR_OUI_BYTES);
+        assertThat(meshcopTxts.modelName).isEqualTo(TEST_MODEL_NAME);
+    }
+
+    @Test
+    public void getMeshcopTxtAttributes_emptyVendorName_accepted() {
+        when(mResources.getString(eq(R.string.config_thread_vendor_name))).thenReturn("");
+
+        MeshcopTxtAttributes meshcopTxts =
+                ThreadNetworkControllerService.getMeshcopTxtAttributes(mResources);
+
+        assertThat(meshcopTxts.vendorName).isEqualTo("");
+    }
+
+    @Test
+    public void getMeshcopTxtAttributes_tooLongVendorName_throwsIllegalStateException() {
+        when(mResources.getString(eq(R.string.config_thread_vendor_name)))
+                .thenReturn("vendor name is 25 bytes!!");
+
+        assertThrows(
+                IllegalStateException.class,
+                () -> ThreadNetworkControllerService.getMeshcopTxtAttributes(mResources));
+    }
+
+    @Test
+    public void getMeshcopTxtAttributes_tooLongModelName_throwsIllegalStateException() {
+        when(mResources.getString(eq(R.string.config_thread_model_name)))
+                .thenReturn("model name is 25 bytes!!!");
+
+        assertThrows(
+                IllegalStateException.class,
+                () -> ThreadNetworkControllerService.getMeshcopTxtAttributes(mResources));
+    }
+
+    @Test
+    public void getMeshcopTxtAttributes_emptyModelName_accepted() {
+        when(mResources.getString(eq(R.string.config_thread_model_name))).thenReturn("");
+
+        var meshcopTxts = ThreadNetworkControllerService.getMeshcopTxtAttributes(mResources);
+        assertThat(meshcopTxts.modelName).isEqualTo("");
+    }
+
+    @Test
+    public void getMeshcopTxtAttributes_invalidVendorOui_throwsIllegalStateException() {
+        assertThrows(
+                IllegalStateException.class, () -> getMeshcopTxtAttributesWithVendorOui("ABCDEFA"));
+        assertThrows(
+                IllegalStateException.class, () -> getMeshcopTxtAttributesWithVendorOui("ABCDEG"));
+        assertThrows(
+                IllegalStateException.class, () -> getMeshcopTxtAttributesWithVendorOui("ABCD"));
+        assertThrows(
+                IllegalStateException.class,
+                () -> getMeshcopTxtAttributesWithVendorOui("AB.CD.EF"));
+    }
+
+    @Test
+    public void getMeshcopTxtAttributes_validVendorOui_accepted() {
+        assertThat(getMeshcopTxtAttributesWithVendorOui("010203")).isEqualTo(new byte[] {1, 2, 3});
+        assertThat(getMeshcopTxtAttributesWithVendorOui("01-02-03"))
+                .isEqualTo(new byte[] {1, 2, 3});
+        assertThat(getMeshcopTxtAttributesWithVendorOui("01:02:03"))
+                .isEqualTo(new byte[] {1, 2, 3});
+        assertThat(getMeshcopTxtAttributesWithVendorOui("ABCDEF"))
+                .isEqualTo(new byte[] {(byte) 0xAB, (byte) 0xCD, (byte) 0xEF});
+        assertThat(getMeshcopTxtAttributesWithVendorOui("abcdef"))
+                .isEqualTo(new byte[] {(byte) 0xAB, (byte) 0xCD, (byte) 0xEF});
+    }
+
+    private byte[] getMeshcopTxtAttributesWithVendorOui(String vendorOui) {
+        when(mResources.getString(eq(R.string.config_thread_vendor_oui))).thenReturn(vendorOui);
+        return ThreadNetworkControllerService.getMeshcopTxtAttributes(mResources).vendorOui;
+    }
+
+    @Test
     public void join_otDaemonRemoteFailure_returnsInternalError() throws Exception {
         mService.initialize();
         final IOperationReceiver mockReceiver = mock(IOperationReceiver.class);
@@ -205,13 +318,13 @@
     }
 
     @Test
-    public void userRestriction_initWithUserRestricted_threadIsDisabled() {
+    public void userRestriction_initWithUserRestricted_otDaemonNotStarted() {
         when(mMockUserManager.hasUserRestriction(eq(DISALLOW_THREAD_NETWORK))).thenReturn(true);
 
         mService.initialize();
         mTestLooper.dispatchAll();
 
-        assertThat(mFakeOtDaemon.getEnabledState()).isEqualTo(STATE_DISABLED);
+        assertThat(mFakeOtDaemon.isInitialized()).isFalse();
     }
 
     @Test
@@ -333,4 +446,70 @@
         verify(mockReceiver, never()).onSuccess(any(ActiveOperationalDataset.class));
         verify(mockReceiver, times(1)).onError(eq(ERROR_INTERNAL_ERROR), anyString());
     }
+
+    @Test
+    public void forceStopOtDaemonForTest_noPermission_throwsSecurityException() {
+        doThrow(new SecurityException(""))
+                .when(mContext)
+                .enforceCallingOrSelfPermission(eq(PERMISSION_THREAD_NETWORK_PRIVILEGED), any());
+
+        assertThrows(
+                SecurityException.class,
+                () -> mService.forceStopOtDaemonForTest(true, new IOperationReceiver.Default()));
+    }
+
+    @Test
+    public void forceStopOtDaemonForTest_enabled_otDaemonDiesAndJoinFails() throws Exception {
+        mService.initialize();
+        IOperationReceiver mockReceiver = mock(IOperationReceiver.class);
+        IOperationReceiver mockJoinReceiver = mock(IOperationReceiver.class);
+
+        mService.forceStopOtDaemonForTest(true, mockReceiver);
+        mTestLooper.dispatchAll();
+        mService.join(DEFAULT_ACTIVE_DATASET, mockJoinReceiver);
+        mTestLooper.dispatchAll();
+
+        verify(mockReceiver, times(1)).onSuccess();
+        assertThat(mFakeOtDaemon.isInitialized()).isFalse();
+        verify(mockJoinReceiver, times(1)).onError(eq(ERROR_THREAD_DISABLED), anyString());
+    }
+
+    @Test
+    public void forceStopOtDaemonForTest_disable_otDaemonRestartsAndJoinSccess() throws Exception {
+        mService.initialize();
+        IOperationReceiver mockReceiver = mock(IOperationReceiver.class);
+        IOperationReceiver mockJoinReceiver = mock(IOperationReceiver.class);
+
+        mService.forceStopOtDaemonForTest(true, mock(IOperationReceiver.class));
+        mTestLooper.dispatchAll();
+        mService.forceStopOtDaemonForTest(false, mockReceiver);
+        mTestLooper.dispatchAll();
+        mService.join(DEFAULT_ACTIVE_DATASET, mockJoinReceiver);
+        mTestLooper.dispatchAll();
+        mTestLooper.moveTimeForward(FakeOtDaemon.JOIN_DELAY.toMillis() + 100);
+        mTestLooper.dispatchAll();
+
+        verify(mockReceiver, times(1)).onSuccess();
+        assertThat(mFakeOtDaemon.isInitialized()).isTrue();
+        verify(mockJoinReceiver, times(1)).onSuccess();
+    }
+
+    @Test
+    public void onOtDaemonDied_joinedNetwork_interfaceStateBackToUp() throws Exception {
+        mService.initialize();
+        final IOperationReceiver mockReceiver = mock(IOperationReceiver.class);
+        mService.join(DEFAULT_ACTIVE_DATASET, mockReceiver);
+        mTestLooper.dispatchAll();
+        mTestLooper.moveTimeForward(FakeOtDaemon.JOIN_DELAY.toMillis() + 100);
+        mTestLooper.dispatchAll();
+
+        Mockito.reset(mMockInfraIfController);
+        mFakeOtDaemon.terminate();
+        mTestLooper.dispatchAll();
+
+        verify(mMockTunIfController, times(1)).onOtDaemonDied();
+        InOrder inOrder = Mockito.inOrder(mMockTunIfController);
+        inOrder.verify(mMockTunIfController, times(1)).setInterfaceUp(false);
+        inOrder.verify(mMockTunIfController, times(1)).setInterfaceUp(true);
+    }
 }
diff --git a/thread/tests/unit/src/com/android/server/thread/ThreadNetworkCountryCodeTest.java b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkCountryCodeTest.java
index 5ca6511..ca9741d 100644
--- a/thread/tests/unit/src/com/android/server/thread/ThreadNetworkCountryCodeTest.java
+++ b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkCountryCodeTest.java
@@ -19,6 +19,7 @@
 import static android.net.thread.ThreadNetworkException.ERROR_INTERNAL_ERROR;
 
 import static com.android.server.thread.ThreadNetworkCountryCode.DEFAULT_COUNTRY_CODE;
+import static com.android.server.thread.ThreadPersistentSettings.THREAD_COUNTRY_CODE;
 
 import static com.google.common.truth.Truth.assertThat;
 
@@ -104,6 +105,7 @@
     @Mock List<SubscriptionInfo> mSubscriptionInfoList;
     @Mock SubscriptionInfo mSubscriptionInfo0;
     @Mock SubscriptionInfo mSubscriptionInfo1;
+    @Mock ThreadPersistentSettings mPersistentSettings;
 
     private ThreadNetworkCountryCode mThreadNetworkCountryCode;
     private boolean mErrorSetCountryCode;
@@ -164,7 +166,8 @@
                 mContext,
                 mTelephonyManager,
                 mSubscriptionManager,
-                oemCountryCode);
+                oemCountryCode,
+                mPersistentSettings);
     }
 
     private static Address newAddress(String countryCode) {
@@ -450,6 +453,14 @@
     }
 
     @Test
+    public void settingsCountryCode_settingsCountryCodeIsActive_settingsCountryCodeIsUsed() {
+        when(mPersistentSettings.get(THREAD_COUNTRY_CODE)).thenReturn(TEST_COUNTRY_CODE_CN);
+        mThreadNetworkCountryCode.initialize();
+
+        assertThat(mThreadNetworkCountryCode.getCountryCode()).isEqualTo(TEST_COUNTRY_CODE_CN);
+    }
+
+    @Test
     public void dump_allCountryCodeInfoAreDumped() {
         StringWriter stringWriter = new StringWriter();
         PrintWriter printWriter = new PrintWriter(stringWriter);
diff --git a/thread/tests/unit/src/com/android/server/thread/ThreadNetworkShellCommandTest.java b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkShellCommandTest.java
index c7e0eca..9f2d0cb 100644
--- a/thread/tests/unit/src/com/android/server/thread/ThreadNetworkShellCommandTest.java
+++ b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkShellCommandTest.java
@@ -16,10 +16,15 @@
 
 package com.android.server.thread;
 
+import static org.mockito.ArgumentMatchers.anyBoolean;
 import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.atLeastOnce;
 import static org.mockito.Mockito.contains;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.eq;
 import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.validateMockitoUsage;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
@@ -45,19 +50,19 @@
 @SmallTest
 public class ThreadNetworkShellCommandTest {
     private static final String TAG = "ThreadNetworkShellCommandTTest";
-    @Mock ThreadNetworkService mThreadNetworkService;
-    @Mock ThreadNetworkCountryCode mThreadNetworkCountryCode;
+    @Mock ThreadNetworkControllerService mControllerService;
+    @Mock ThreadNetworkCountryCode mCountryCode;
     @Mock PrintWriter mErrorWriter;
     @Mock PrintWriter mOutputWriter;
 
-    ThreadNetworkShellCommand mThreadNetworkShellCommand;
+    ThreadNetworkShellCommand mShellCommand;
 
     @Before
     public void setUp() throws Exception {
         MockitoAnnotations.initMocks(this);
 
-        mThreadNetworkShellCommand = new ThreadNetworkShellCommand(mThreadNetworkCountryCode);
-        mThreadNetworkShellCommand.setPrintWriters(mOutputWriter, mErrorWriter);
+        mShellCommand = new ThreadNetworkShellCommand(mControllerService, mCountryCode);
+        mShellCommand.setPrintWriters(mOutputWriter, mErrorWriter);
     }
 
     @After
@@ -68,9 +73,9 @@
     @Test
     public void getCountryCode_executeInUnrootedShell_allowed() {
         BinderUtil.setUid(Process.SHELL_UID);
-        when(mThreadNetworkCountryCode.getCountryCode()).thenReturn("US");
+        when(mCountryCode.getCountryCode()).thenReturn("US");
 
-        mThreadNetworkShellCommand.exec(
+        mShellCommand.exec(
                 new Binder(),
                 new FileDescriptor(),
                 new FileDescriptor(),
@@ -84,14 +89,14 @@
     public void forceSetCountryCodeEnabled_executeInUnrootedShell_notAllowed() {
         BinderUtil.setUid(Process.SHELL_UID);
 
-        mThreadNetworkShellCommand.exec(
+        mShellCommand.exec(
                 new Binder(),
                 new FileDescriptor(),
                 new FileDescriptor(),
                 new FileDescriptor(),
                 new String[] {"force-country-code", "enabled", "US"});
 
-        verify(mThreadNetworkCountryCode, never()).setOverrideCountryCode(eq("US"));
+        verify(mCountryCode, never()).setOverrideCountryCode(eq("US"));
         verify(mErrorWriter).println(contains("force-country-code"));
     }
 
@@ -99,28 +104,28 @@
     public void forceSetCountryCodeEnabled_executeInRootedShell_allowed() {
         BinderUtil.setUid(Process.ROOT_UID);
 
-        mThreadNetworkShellCommand.exec(
+        mShellCommand.exec(
                 new Binder(),
                 new FileDescriptor(),
                 new FileDescriptor(),
                 new FileDescriptor(),
                 new String[] {"force-country-code", "enabled", "US"});
 
-        verify(mThreadNetworkCountryCode).setOverrideCountryCode(eq("US"));
+        verify(mCountryCode).setOverrideCountryCode(eq("US"));
     }
 
     @Test
     public void forceSetCountryCodeDisabled_executeInUnrootedShell_notAllowed() {
         BinderUtil.setUid(Process.SHELL_UID);
 
-        mThreadNetworkShellCommand.exec(
+        mShellCommand.exec(
                 new Binder(),
                 new FileDescriptor(),
                 new FileDescriptor(),
                 new FileDescriptor(),
                 new String[] {"force-country-code", "disabled"});
 
-        verify(mThreadNetworkCountryCode, never()).setOverrideCountryCode(any());
+        verify(mCountryCode, never()).setOverrideCountryCode(any());
         verify(mErrorWriter).println(contains("force-country-code"));
     }
 
@@ -128,13 +133,64 @@
     public void forceSetCountryCodeDisabled_executeInRootedShell_allowed() {
         BinderUtil.setUid(Process.ROOT_UID);
 
-        mThreadNetworkShellCommand.exec(
+        mShellCommand.exec(
                 new Binder(),
                 new FileDescriptor(),
                 new FileDescriptor(),
                 new FileDescriptor(),
                 new String[] {"force-country-code", "disabled"});
 
-        verify(mThreadNetworkCountryCode).clearOverrideCountryCode();
+        verify(mCountryCode).clearOverrideCountryCode();
+    }
+
+    @Test
+    public void forceStopOtDaemon_executeInUnrootedShell_failedAndServiceApiNotCalled() {
+        BinderUtil.setUid(Process.SHELL_UID);
+
+        mShellCommand.exec(
+                new Binder(),
+                new FileDescriptor(),
+                new FileDescriptor(),
+                new FileDescriptor(),
+                new String[] {"force-stop-ot-daemon", "enabled"});
+
+        verify(mControllerService, never()).forceStopOtDaemonForTest(anyBoolean(), any());
+        verify(mErrorWriter, atLeastOnce()).println(contains("force-stop-ot-daemon"));
+        verify(mOutputWriter, never()).println();
+    }
+
+    @Test
+    public void forceStopOtDaemon_serviceThrows_failed() {
+        BinderUtil.setUid(Process.ROOT_UID);
+        doThrow(new SecurityException(""))
+                .when(mControllerService)
+                .forceStopOtDaemonForTest(eq(true), any());
+
+        mShellCommand.exec(
+                new Binder(),
+                new FileDescriptor(),
+                new FileDescriptor(),
+                new FileDescriptor(),
+                new String[] {"force-stop-ot-daemon", "enabled"});
+
+        verify(mControllerService, times(1)).forceStopOtDaemonForTest(eq(true), any());
+        verify(mOutputWriter, never()).println();
+    }
+
+    @Test
+    public void forceStopOtDaemon_serviceApiTimeout_failedWithTimeoutError() {
+        BinderUtil.setUid(Process.ROOT_UID);
+        doNothing().when(mControllerService).forceStopOtDaemonForTest(eq(true), any());
+
+        mShellCommand.exec(
+                new Binder(),
+                new FileDescriptor(),
+                new FileDescriptor(),
+                new FileDescriptor(),
+                new String[] {"force-stop-ot-daemon", "enabled"});
+
+        verify(mControllerService, times(1)).forceStopOtDaemonForTest(eq(true), any());
+        verify(mErrorWriter, atLeastOnce()).println(contains("timeout"));
+        verify(mOutputWriter, never()).println();
     }
 }
diff --git a/thread/tests/unit/src/com/android/server/thread/ThreadPersistentSettingsTest.java b/thread/tests/unit/src/com/android/server/thread/ThreadPersistentSettingsTest.java
new file mode 100644
index 0000000..7d2fe91
--- /dev/null
+++ b/thread/tests/unit/src/com/android/server/thread/ThreadPersistentSettingsTest.java
@@ -0,0 +1,182 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.thread;
+
+import static com.android.server.thread.ThreadPersistentSettings.THREAD_COUNTRY_CODE;
+import static com.android.server.thread.ThreadPersistentSettings.THREAD_ENABLED;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.anyInt;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.validateMockitoUsage;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.content.res.Resources;
+import android.os.PersistableBundle;
+import android.util.AtomicFile;
+
+import androidx.test.filters.SmallTest;
+import androidx.test.runner.AndroidJUnit4;
+
+import com.android.connectivity.resources.R;
+import com.android.server.connectivity.ConnectivityResources;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.io.ByteArrayOutputStream;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+
+/** Unit tests for {@link ThreadPersistentSettings}. */
+@RunWith(AndroidJUnit4.class)
+@SmallTest
+public class ThreadPersistentSettingsTest {
+    private static final String TEST_COUNTRY_CODE = "CN";
+
+    @Mock private AtomicFile mAtomicFile;
+    @Mock Resources mResources;
+    @Mock ConnectivityResources mConnectivityResources;
+
+    private ThreadPersistentSettings mThreadPersistentSettings;
+
+    @Before
+    public void setUp() throws Exception {
+        MockitoAnnotations.initMocks(this);
+
+        when(mConnectivityResources.get()).thenReturn(mResources);
+        when(mResources.getBoolean(eq(R.bool.config_thread_default_enabled))).thenReturn(true);
+
+        FileOutputStream fos = mock(FileOutputStream.class);
+        when(mAtomicFile.startWrite()).thenReturn(fos);
+        mThreadPersistentSettings =
+                new ThreadPersistentSettings(mAtomicFile, mConnectivityResources);
+    }
+
+    /** Called after each test */
+    @After
+    public void tearDown() {
+        validateMockitoUsage();
+    }
+
+    @Test
+    public void initialize_readsFromFile() throws Exception {
+        byte[] data = createXmlForParsing(THREAD_ENABLED.key, false);
+        setupAtomicFileMockForRead(data);
+
+        mThreadPersistentSettings.initialize();
+
+        assertThat(mThreadPersistentSettings.get(THREAD_ENABLED)).isFalse();
+    }
+
+    @Test
+    public void initialize_ThreadDisabledInResources_returnsThreadDisabled() throws Exception {
+        when(mResources.getBoolean(eq(R.bool.config_thread_default_enabled))).thenReturn(false);
+        setupAtomicFileMockForRead(new byte[0]);
+
+        mThreadPersistentSettings.initialize();
+
+        assertThat(mThreadPersistentSettings.get(THREAD_ENABLED)).isFalse();
+    }
+
+    @Test
+    public void initialize_ThreadDisabledInResourcesButEnabledInXml_returnsThreadEnabled()
+            throws Exception {
+        when(mResources.getBoolean(eq(R.bool.config_thread_default_enabled))).thenReturn(false);
+        byte[] data = createXmlForParsing(THREAD_ENABLED.key, true);
+        setupAtomicFileMockForRead(data);
+
+        mThreadPersistentSettings.initialize();
+
+        assertThat(mThreadPersistentSettings.get(THREAD_ENABLED)).isTrue();
+    }
+
+    @Test
+    public void put_ThreadFeatureEnabledTrue_returnsTrue() throws Exception {
+        mThreadPersistentSettings.put(THREAD_ENABLED.key, true);
+
+        assertThat(mThreadPersistentSettings.get(THREAD_ENABLED)).isTrue();
+        // Confirm that file writes have been triggered.
+        verify(mAtomicFile).startWrite();
+        verify(mAtomicFile).finishWrite(any());
+    }
+
+    @Test
+    public void put_ThreadFeatureEnabledFalse_returnsFalse() throws Exception {
+        mThreadPersistentSettings.put(THREAD_ENABLED.key, false);
+
+        assertThat(mThreadPersistentSettings.get(THREAD_ENABLED)).isFalse();
+        // Confirm that file writes have been triggered.
+        verify(mAtomicFile).startWrite();
+        verify(mAtomicFile).finishWrite(any());
+    }
+
+    @Test
+    public void put_ThreadCountryCodeString_returnsString() throws Exception {
+        mThreadPersistentSettings.put(THREAD_COUNTRY_CODE.key, TEST_COUNTRY_CODE);
+
+        assertThat(mThreadPersistentSettings.get(THREAD_COUNTRY_CODE)).isEqualTo(TEST_COUNTRY_CODE);
+
+        // Confirm that file writes have been triggered.
+        verify(mAtomicFile).startWrite();
+        verify(mAtomicFile).finishWrite(any());
+    }
+
+    @Test
+    public void put_ThreadCountryCodeNull_returnsNull() throws Exception {
+        mThreadPersistentSettings.put(THREAD_COUNTRY_CODE.key, null);
+
+        assertThat(mThreadPersistentSettings.get(THREAD_COUNTRY_CODE)).isNull();
+
+        // Confirm that file writes have been triggered.
+        verify(mAtomicFile).startWrite();
+        verify(mAtomicFile).finishWrite(any());
+    }
+
+    private byte[] createXmlForParsing(String key, Boolean value) throws Exception {
+        PersistableBundle bundle = new PersistableBundle();
+        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+        bundle.putBoolean(key, value);
+        bundle.writeToStream(outputStream);
+        return outputStream.toByteArray();
+    }
+
+    private void setupAtomicFileMockForRead(byte[] dataToRead) throws Exception {
+        FileInputStream is = mock(FileInputStream.class);
+        when(mAtomicFile.openRead()).thenReturn(is);
+        when(is.available()).thenReturn(dataToRead.length).thenReturn(0);
+        doAnswer(
+                        invocation -> {
+                            byte[] data = invocation.getArgument(0);
+                            int pos = invocation.getArgument(1);
+                            if (pos == dataToRead.length) return 0; // read complete.
+                            System.arraycopy(dataToRead, 0, data, 0, dataToRead.length);
+                            return dataToRead.length;
+                        })
+                .when(is)
+                .read(any(), anyInt(), anyInt());
+    }
+}
diff --git a/thread/tests/utils/Android.bp b/thread/tests/utils/Android.bp
new file mode 100644
index 0000000..726ec9d
--- /dev/null
+++ b/thread/tests/utils/Android.bp
@@ -0,0 +1,38 @@
+//
+// 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 {
+    default_team: "trendy_team_fwk_thread_network",
+    default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+java_library {
+    name: "ThreadNetworkTestUtils",
+    min_sdk_version: "30",
+    static_libs: [
+        "compatibility-device-util-axt",
+        "net-tests-utils",
+        "net-utils-device-common",
+        "net-utils-device-common-bpf",
+        "net-utils-device-common-struct-base",
+    ],
+    srcs: [
+        "src/**/*.java",
+    ],
+    defaults: [
+        "framework-connectivity-test-defaults",
+    ],
+}
diff --git a/thread/tests/utils/src/android/net/thread/utils/TapTestNetworkTracker.java b/thread/tests/utils/src/android/net/thread/utils/TapTestNetworkTracker.java
new file mode 100644
index 0000000..b586a19
--- /dev/null
+++ b/thread/tests/utils/src/android/net/thread/utils/TapTestNetworkTracker.java
@@ -0,0 +1,191 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package android.net.thread.utils;
+
+import static android.Manifest.permission.MANAGE_TEST_NETWORKS;
+import static android.net.InetAddresses.parseNumericAddress;
+import static android.net.NetworkCapabilities.NET_CAPABILITY_TRUSTED;
+import static android.net.NetworkCapabilities.TRANSPORT_TEST;
+import static android.system.OsConstants.AF_INET6;
+import static android.system.OsConstants.IPPROTO_UDP;
+import static android.system.OsConstants.SOCK_DGRAM;
+
+import static com.android.testutils.RecorderCallback.CallbackEntry.LINK_PROPERTIES_CHANGED;
+import static com.android.testutils.TestPermissionUtil.runAsShell;
+
+import android.content.Context;
+import android.net.ConnectivityManager;
+import android.net.LinkAddress;
+import android.net.LinkProperties;
+import android.net.Network;
+import android.net.NetworkAgentConfig;
+import android.net.NetworkCapabilities;
+import android.net.NetworkRequest;
+import android.net.TestNetworkInterface;
+import android.net.TestNetworkManager;
+import android.net.TestNetworkSpecifier;
+import android.os.Looper;
+import android.system.ErrnoException;
+import android.system.Os;
+
+import com.android.compatibility.common.util.PollingCheck;
+import com.android.testutils.TestableNetworkAgent;
+import com.android.testutils.TestableNetworkCallback;
+
+import java.io.FileDescriptor;
+import java.io.IOException;
+import java.net.InterfaceAddress;
+import java.net.NetworkInterface;
+import java.net.SocketException;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/** A class that can create/destroy a test network based on TAP interface. */
+public final class TapTestNetworkTracker {
+    private static final Duration TIMEOUT = Duration.ofSeconds(2);
+    private final Context mContext;
+    private final Looper mLooper;
+    private TestNetworkInterface mInterface;
+    private TestableNetworkAgent mAgent;
+    private Network mNetwork;
+    private final TestableNetworkCallback mNetworkCallback;
+    private final ConnectivityManager mConnectivityManager;
+
+    /**
+     * Constructs a {@link TapTestNetworkTracker}.
+     *
+     * <p>It creates a TAP interface (e.g. testtap0) and registers a test network using that
+     * interface. It also requests the test network by {@link ConnectivityManager#requestNetwork} so
+     * the test network won't be automatically turned down by {@link
+     * com.android.server.ConnectivityService}.
+     */
+    public TapTestNetworkTracker(Context context, Looper looper) {
+        mContext = context;
+        mLooper = looper;
+        mConnectivityManager = mContext.getSystemService(ConnectivityManager.class);
+        mNetworkCallback = new TestableNetworkCallback();
+        runAsShell(MANAGE_TEST_NETWORKS, this::setUpTestNetwork);
+    }
+
+    /** Tears down the test network. */
+    public void tearDown() {
+        runAsShell(MANAGE_TEST_NETWORKS, this::tearDownTestNetwork);
+    }
+
+    /** Returns the interface name of the test network. */
+    public String getInterfaceName() {
+        return mInterface.getInterfaceName();
+    }
+
+    /** Returns the {@link android.net.Network} of the test network. */
+    public Network getNetwork() {
+        return mNetwork;
+    }
+
+    private void setUpTestNetwork() throws Exception {
+        mInterface = mContext.getSystemService(TestNetworkManager.class).createTapInterface();
+
+        mConnectivityManager.requestNetwork(newNetworkRequest(), mNetworkCallback);
+
+        LinkProperties lp = new LinkProperties();
+        lp.setInterfaceName(getInterfaceName());
+        mAgent =
+                new TestableNetworkAgent(
+                        mContext,
+                        mLooper,
+                        newNetworkCapabilities(),
+                        lp,
+                        new NetworkAgentConfig.Builder().build());
+        mNetwork = mAgent.register();
+        mAgent.markConnected();
+
+        PollingCheck.check(
+                "No usable address on interface",
+                TIMEOUT.toMillis(),
+                () -> hasUsableAddress(mNetwork, getInterfaceName()));
+
+        lp.setLinkAddresses(makeLinkAddresses());
+        mAgent.sendLinkProperties(lp);
+        mNetworkCallback.eventuallyExpect(
+                LINK_PROPERTIES_CHANGED,
+                TIMEOUT.toMillis(),
+                l -> !l.getLp().getAddresses().isEmpty());
+    }
+
+    private void tearDownTestNetwork() throws IOException {
+        mConnectivityManager.unregisterNetworkCallback(mNetworkCallback);
+        mAgent.unregister();
+        mInterface.getFileDescriptor().close();
+        mAgent.waitForIdle(TIMEOUT.toMillis());
+    }
+
+    private NetworkRequest newNetworkRequest() {
+        return new NetworkRequest.Builder()
+                .removeCapability(NET_CAPABILITY_TRUSTED)
+                .addTransportType(TRANSPORT_TEST)
+                .setNetworkSpecifier(new TestNetworkSpecifier(getInterfaceName()))
+                .build();
+    }
+
+    private NetworkCapabilities newNetworkCapabilities() {
+        return new NetworkCapabilities()
+                .removeCapability(NET_CAPABILITY_TRUSTED)
+                .addTransportType(TRANSPORT_TEST)
+                .setNetworkSpecifier(new TestNetworkSpecifier(getInterfaceName()));
+    }
+
+    private List<LinkAddress> makeLinkAddresses() {
+        List<LinkAddress> linkAddresses = new ArrayList<>();
+        List<InterfaceAddress> interfaceAddresses = Collections.emptyList();
+
+        try {
+            interfaceAddresses =
+                    NetworkInterface.getByName(getInterfaceName()).getInterfaceAddresses();
+        } catch (SocketException ignored) {
+            // Ignore failures when getting the addresses.
+        }
+
+        for (InterfaceAddress address : interfaceAddresses) {
+            linkAddresses.add(
+                    new LinkAddress(address.getAddress(), address.getNetworkPrefixLength()));
+        }
+
+        return linkAddresses;
+    }
+
+    private static boolean hasUsableAddress(Network network, String interfaceName) {
+        try {
+            if (NetworkInterface.getByName(interfaceName).getInterfaceAddresses().isEmpty()) {
+                return false;
+            }
+        } catch (SocketException e) {
+            return false;
+        }
+        // Check if the link-local address can be used. Address flags are not available without
+        // elevated permissions, so check that bindSocket works.
+        try {
+            FileDescriptor sock = Os.socket(AF_INET6, SOCK_DGRAM, IPPROTO_UDP);
+            network.bindSocket(sock);
+            Os.connect(sock, parseNumericAddress("ff02::fb%" + interfaceName), 12345);
+            Os.close(sock);
+        } catch (ErrnoException | IOException e) {
+            return false;
+        }
+        return true;
+    }
+}
diff --git a/thread/tests/utils/src/android/net/thread/utils/ThreadFeatureCheckerRule.java b/thread/tests/utils/src/android/net/thread/utils/ThreadFeatureCheckerRule.java
new file mode 100644
index 0000000..bee9ceb
--- /dev/null
+++ b/thread/tests/utils/src/android/net/thread/utils/ThreadFeatureCheckerRule.java
@@ -0,0 +1,160 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net.thread.utils;
+
+import static com.android.testutils.DeviceInfoUtils.isKernelVersionAtLeast;
+
+import static org.junit.Assume.assumeTrue;
+
+import android.content.Context;
+import android.net.thread.ThreadNetworkManager;
+import android.os.SystemProperties;
+import android.os.VintfRuntimeInfo;
+
+import androidx.test.core.app.ApplicationProvider;
+
+import org.junit.rules.TestRule;
+import org.junit.runner.Description;
+import org.junit.runners.model.Statement;
+
+import java.lang.annotation.Annotation;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * A rule used to skip Thread tests when the device doesn't support a specific feature indicated by
+ * {@code ThreadFeatureCheckerRule.Requires*}.
+ */
+public final class ThreadFeatureCheckerRule implements TestRule {
+    private static final String KERNEL_VERSION_MULTICAST_ROUTING_SUPPORTED = "5.15.0";
+    private static final int KERNEL_ANDROID_VERSION_MULTICAST_ROUTING_SUPPORTED = 14;
+
+    /**
+     * Annotates a test class or method requires the Thread feature to run.
+     *
+     * <p>In Absence of the Thread feature, the test class or method will be ignored.
+     */
+    @Retention(RetentionPolicy.RUNTIME)
+    @Target({ElementType.METHOD, ElementType.TYPE})
+    public @interface RequiresThreadFeature {}
+
+    /**
+     * Annotates a test class or method requires the kernel IPv6 multicast routing feature to run.
+     *
+     * <p>In Absence of the multicast routing feature, the test class or method will be ignored.
+     */
+    @Retention(RetentionPolicy.RUNTIME)
+    @Target({ElementType.METHOD, ElementType.TYPE})
+    public @interface RequiresIpv6MulticastRouting {}
+
+    /**
+     * Annotates a test class or method requires the simulation Thread device (i.e. ot-cli-ftd) to
+     * run.
+     *
+     * <p>In Absence of the simulation device, the test class or method will be ignored.
+     */
+    @Retention(RetentionPolicy.RUNTIME)
+    @Target({ElementType.METHOD, ElementType.TYPE})
+    public @interface RequiresSimulationThreadDevice {}
+
+    @Override
+    public Statement apply(final Statement base, Description description) {
+        return new Statement() {
+            @Override
+            public void evaluate() throws Throwable {
+                if (hasAnnotation(RequiresThreadFeature.class, description)) {
+                    assumeTrue(
+                            "Skipping test because the Thread feature is unavailable",
+                            hasThreadFeature());
+                }
+
+                if (hasAnnotation(RequiresIpv6MulticastRouting.class, description)) {
+                    assumeTrue(
+                            "Skipping test because kernel IPv6 multicast routing is unavailable",
+                            hasIpv6MulticastRouting());
+                }
+
+                if (hasAnnotation(RequiresSimulationThreadDevice.class, description)) {
+                    assumeTrue(
+                            "Skipping test because simulation Thread device is unavailable",
+                            hasSimulationThreadDevice());
+                }
+
+                base.evaluate();
+            }
+        };
+    }
+
+    /** Returns {@code true} if a test method or the test class is annotated with annotation. */
+    private <T extends Annotation> boolean hasAnnotation(
+            Class<T> annotationClass, Description description) {
+        // Method annotation
+        boolean hasAnnotation = description.getAnnotation(annotationClass) != null;
+
+        // Class annotation
+        Class<?> clazz = description.getTestClass();
+        while (!hasAnnotation && clazz != Object.class) {
+            hasAnnotation |= clazz.getAnnotation(annotationClass) != null;
+            clazz = clazz.getSuperclass();
+        }
+
+        return hasAnnotation;
+    }
+
+    /** Returns {@code true} if this device has the Thread feature supported. */
+    private static boolean hasThreadFeature() {
+        final Context context = ApplicationProvider.getApplicationContext();
+        return context.getSystemService(ThreadNetworkManager.class) != null;
+    }
+
+    /**
+     * Returns {@code true} if this device has the kernel IPv6 multicast routing feature enabled.
+     */
+    private static boolean hasIpv6MulticastRouting() {
+        // The kernel IPv6 multicast routing (i.e. IPV6_MROUTE) is enabled on kernel version
+        // android14-5.15.0 and later
+        return isKernelVersionAtLeast(KERNEL_VERSION_MULTICAST_ROUTING_SUPPORTED)
+                && isKernelAndroidVersionAtLeast(
+                        KERNEL_ANDROID_VERSION_MULTICAST_ROUTING_SUPPORTED);
+    }
+
+    /**
+     * Returns {@code true} if the android version in the kernel version of this device is equal to
+     * or larger than the given {@code minVersion}.
+     */
+    private static boolean isKernelAndroidVersionAtLeast(int minVersion) {
+        final String osRelease = VintfRuntimeInfo.getOsRelease();
+        final Pattern pattern = Pattern.compile("android(\\d+)");
+        Matcher matcher = pattern.matcher(osRelease);
+
+        if (matcher.find()) {
+            int version = Integer.parseInt(matcher.group(1));
+            return (version >= minVersion);
+        }
+        return false;
+    }
+
+    /** Returns {@code true} if the simulation Thread device is supported. */
+    private static boolean hasSimulationThreadDevice() {
+        // Simulation radio is supported on only Cuttlefish
+        return SystemProperties.get("ro.product.model").startsWith("Cuttlefish");
+    }
+}
diff --git a/tools/Android.bp b/tools/Android.bp
index 9216b5b..2c2ed14 100644
--- a/tools/Android.bp
+++ b/tools/Android.bp
@@ -83,6 +83,8 @@
     ],
     data: [
         "testdata/test-jarjar-excludes.txt",
+        // txt with Test classes to test they aren't included when added to jarjar excludes
+        "testdata/test-jarjar-excludes-testclass.txt",
         // two unsupportedappusage lists with different classes to test using multiple lists
         "testdata/test-unsupportedappusage.txt",
         "testdata/test-other-unsupportedappusage.txt",
diff --git a/tools/gen_jarjar_test.py b/tools/gen_jarjar_test.py
index f5bf499..12038e9 100644
--- a/tools/gen_jarjar_test.py
+++ b/tools/gen_jarjar_test.py
@@ -84,6 +84,31 @@
             'rule test.utils.TestUtilClass$TestInnerClassTest jarjar.prefix.@0\n',
             'rule test.utils.TestUtilClass$TestInnerClassTest$* jarjar.prefix.@0\n'], lines)
 
+    def test_gen_rules_repeated_testclass_excluded(self):
+        args = gen_jarjar.parse_arguments([
+            "jarjar-rules-generator-testjavalib.jar",
+            "--prefix", "jarjar.prefix",
+            "--output", "test-output-rules.txt",
+            "--apistubs", "framework-connectivity.stubs.module_lib.jar",
+            "--unsupportedapi", ":testdata/test-unsupportedappusage.txt",
+            "--excludes", "testdata/test-jarjar-excludes-testclass.txt",
+        ])
+        gen_jarjar.make_jarjar_rules(args)
+
+        with open(args.output) as out:
+            lines = out.readlines()
+
+        self.maxDiff = None
+        self.assertListEqual([
+            'rule android.net.IpSecTransform jarjar.prefix.@0\n',
+            'rule test.unsupportedappusage.OtherUnsupportedUsageClass jarjar.prefix.@0\n',
+            'rule test.unsupportedappusage.OtherUnsupportedUsageClassTest jarjar.prefix.@0\n',
+            'rule test.unsupportedappusage.OtherUnsupportedUsageClassTest$* jarjar.prefix.@0\n',
+            'rule test.utils.TestUtilClass jarjar.prefix.@0\n',
+            'rule test.utils.TestUtilClass$TestInnerClass jarjar.prefix.@0\n',
+            'rule test.utils.TestUtilClass$TestInnerClassTest jarjar.prefix.@0\n',
+            'rule test.utils.TestUtilClass$TestInnerClassTest$* jarjar.prefix.@0\n'], lines)
+
 
 if __name__ == '__main__':
     # Need verbosity=2 for the test results parser to find results
diff --git a/tools/testdata/test-jarjar-excludes-testclass.txt b/tools/testdata/test-jarjar-excludes-testclass.txt
new file mode 100644
index 0000000..f7cc2cb
--- /dev/null
+++ b/tools/testdata/test-jarjar-excludes-testclass.txt
@@ -0,0 +1,7 @@
+# Test file for excluded classes
+test\.jarj.rexcluded\.JarjarExcludedCla.s
+test\.jarjarexcluded\.JarjarExcludedClass\$TestInnerCl.ss
+
+# Exclude actual test files
+test\.utils\.TestUtilClassTest
+android\.net\.IpSecTransformTest
\ No newline at end of file