Merge "Extend tethering enable/disable timeout to 30 seconds" into main
diff --git a/DnsResolver/Android.bp b/DnsResolver/Android.bp
new file mode 100644
index 0000000..d133034
--- /dev/null
+++ b/DnsResolver/Android.bp
@@ -0,0 +1,83 @@
+//
+// 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_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+cc_library {
+    name: "libcom.android.tethering.dns_helper",
+    version_script: "libcom.android.tethering.dns_helper.map.txt",
+    stubs: {
+        versions: [
+            "1",
+        ],
+        symbol_file: "libcom.android.tethering.dns_helper.map.txt",
+    },
+    defaults: ["netd_defaults"],
+    header_libs: [
+        "bpf_connectivity_headers",
+        "libcutils_headers",
+    ],
+    srcs: [
+        "DnsBpfHelper.cpp",
+        "DnsHelper.cpp",
+    ],
+    static_libs: [
+        "libmodules-utils-build",
+    ],
+    shared_libs: [
+        "libbase",
+    ],
+    export_include_dirs: ["include"],
+    header_abi_checker: {
+        enabled: true,
+        symbol_file: "libcom.android.tethering.dns_helper.map.txt",
+    },
+    sanitize: {
+        cfi: true,
+    },
+    apex_available: ["com.android.tethering"],
+    min_sdk_version: "30",
+}
+
+cc_test {
+    name: "dns_helper_unit_test",
+    defaults: ["netd_defaults"],
+    test_suites: ["general-tests", "mts-tethering"],
+    test_config_template: ":net_native_test_config_template",
+    header_libs: [
+        "bpf_connectivity_headers",
+    ],
+    srcs: [
+        "DnsBpfHelperTest.cpp",
+    ],
+    static_libs: [
+        "libcom.android.tethering.dns_helper",
+    ],
+    shared_libs: [
+       "libbase",
+       "libcutils",
+    ],
+    compile_multilib: "both",
+    multilib: {
+        lib32: {
+            suffix: "32",
+        },
+        lib64: {
+            suffix: "64",
+        },
+    },
+}
diff --git a/DnsResolver/DnsBpfHelper.cpp b/DnsResolver/DnsBpfHelper.cpp
new file mode 100644
index 0000000..37c46ca
--- /dev/null
+++ b/DnsResolver/DnsBpfHelper.cpp
@@ -0,0 +1,78 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#define LOG_TAG "DnsBpfHelper"
+
+#include "DnsBpfHelper.h"
+
+#include <android-base/logging.h>
+#include <android-modules-utils/sdk_level.h>
+
+namespace android {
+namespace net {
+
+#define RETURN_IF_RESULT_NOT_OK(result)                                                            \
+  do {                                                                                             \
+    if (!result.ok()) {                                                                            \
+      LOG(ERROR) << "L" << __LINE__ << " " << __func__ << ": " << strerror(result.error().code()); \
+      return result.error();                                                                       \
+    }                                                                                              \
+  } while (0)
+
+base::Result<void> DnsBpfHelper::init() {
+  if (!android::modules::sdklevel::IsAtLeastT()) {
+    LOG(ERROR) << __func__ << ": Unsupported before Android T.";
+    return base::Error(EOPNOTSUPP);
+  }
+
+  RETURN_IF_RESULT_NOT_OK(mConfigurationMap.init(CONFIGURATION_MAP_PATH));
+  RETURN_IF_RESULT_NOT_OK(mUidOwnerMap.init(UID_OWNER_MAP_PATH));
+  RETURN_IF_RESULT_NOT_OK(mDataSaverEnabledMap.init(DATA_SAVER_ENABLED_MAP_PATH));
+  return {};
+}
+
+base::Result<bool> DnsBpfHelper::isUidNetworkingBlocked(uid_t uid, bool metered) {
+  if (is_system_uid(uid)) return false;
+  if (!mConfigurationMap.isValid() || !mUidOwnerMap.isValid()) {
+    LOG(ERROR) << __func__
+               << ": BPF maps are not ready. Forgot to call ADnsHelper_init?";
+    return base::Error(EUNATCH);
+  }
+
+  auto enabledRules = mConfigurationMap.readValue(UID_RULES_CONFIGURATION_KEY);
+  RETURN_IF_RESULT_NOT_OK(enabledRules);
+
+  auto value = mUidOwnerMap.readValue(uid);
+  uint32_t uidRules = value.ok() ? value.value().rule : 0;
+
+  // For doze mode, battery saver, low power standby.
+  if (isBlockedByUidRules(enabledRules.value(), uidRules)) return true;
+
+  // For data saver.
+  if (!metered) return false;
+
+  // The background data setting (PENALTY_BOX_MATCH) and unrestricted data usage setting
+  // (HAPPY_BOX_MATCH) for individual apps override the system wide Data Saver setting.
+  if (uidRules & PENALTY_BOX_MATCH) return true;
+  if (uidRules & HAPPY_BOX_MATCH) return false;
+
+  auto dataSaverSetting = mDataSaverEnabledMap.readValue(DATA_SAVER_ENABLED_KEY);
+  RETURN_IF_RESULT_NOT_OK(dataSaverSetting);
+  return dataSaverSetting.value();
+}
+
+}  // namespace net
+}  // namespace android
diff --git a/DnsResolver/DnsBpfHelper.h b/DnsResolver/DnsBpfHelper.h
new file mode 100644
index 0000000..f1c3992
--- /dev/null
+++ b/DnsResolver/DnsBpfHelper.h
@@ -0,0 +1,46 @@
+/*
+ * 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.
+ */
+
+#pragma once
+
+#include <android-base/result.h>
+
+#include "bpf/BpfMap.h"
+#include "netd.h"
+
+namespace android {
+namespace net {
+
+class DnsBpfHelper {
+ public:
+  DnsBpfHelper() = default;
+  DnsBpfHelper(const DnsBpfHelper&) = delete;
+  DnsBpfHelper& operator=(const DnsBpfHelper&) = delete;
+
+  base::Result<void> init();
+  base::Result<bool> isUidNetworkingBlocked(uid_t uid, bool metered);
+
+ private:
+  android::bpf::BpfMapRO<uint32_t, uint32_t> mConfigurationMap;
+  android::bpf::BpfMapRO<uint32_t, UidOwnerValue> mUidOwnerMap;
+  android::bpf::BpfMapRO<uint32_t, bool> mDataSaverEnabledMap;
+
+  // For testing
+  friend class DnsBpfHelperTest;
+};
+
+}  // namespace net
+}  // namespace android
diff --git a/DnsResolver/DnsBpfHelperTest.cpp b/DnsResolver/DnsBpfHelperTest.cpp
new file mode 100644
index 0000000..67b5b95
--- /dev/null
+++ b/DnsResolver/DnsBpfHelperTest.cpp
@@ -0,0 +1,200 @@
+/*
+ * 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.
+ */
+
+#include <gtest/gtest.h>
+#include <private/android_filesystem_config.h>
+
+#define BPF_MAP_MAKE_VISIBLE_FOR_TESTING
+#include "DnsBpfHelper.h"
+
+using namespace android::bpf;  // NOLINT(google-build-using-namespace): exempted
+
+namespace android {
+namespace net {
+
+constexpr int TEST_MAP_SIZE = 2;
+
+#define ASSERT_VALID(x) ASSERT_TRUE((x).isValid())
+
+class DnsBpfHelperTest : public ::testing::Test {
+ protected:
+  DnsBpfHelper mDnsBpfHelper;
+  BpfMap<uint32_t, uint32_t> mFakeConfigurationMap;
+  BpfMap<uint32_t, UidOwnerValue> mFakeUidOwnerMap;
+  BpfMap<uint32_t, bool> mFakeDataSaverEnabledMap;
+
+  void SetUp() {
+    mFakeConfigurationMap.resetMap(BPF_MAP_TYPE_ARRAY, CONFIGURATION_MAP_SIZE);
+    ASSERT_VALID(mFakeConfigurationMap);
+
+    mFakeUidOwnerMap.resetMap(BPF_MAP_TYPE_HASH, TEST_MAP_SIZE);
+    ASSERT_VALID(mFakeUidOwnerMap);
+
+    mFakeDataSaverEnabledMap.resetMap(BPF_MAP_TYPE_ARRAY, DATA_SAVER_ENABLED_MAP_SIZE);
+    ASSERT_VALID(mFakeDataSaverEnabledMap);
+
+    mDnsBpfHelper.mConfigurationMap = mFakeConfigurationMap;
+    ASSERT_VALID(mDnsBpfHelper.mConfigurationMap);
+    mDnsBpfHelper.mUidOwnerMap = mFakeUidOwnerMap;
+    ASSERT_VALID(mDnsBpfHelper.mUidOwnerMap);
+    mDnsBpfHelper.mDataSaverEnabledMap = mFakeDataSaverEnabledMap;
+    ASSERT_VALID(mDnsBpfHelper.mDataSaverEnabledMap);
+  }
+
+  void ResetAllMaps() {
+    mDnsBpfHelper.mConfigurationMap.reset();
+    mDnsBpfHelper.mUidOwnerMap.reset();
+    mDnsBpfHelper.mDataSaverEnabledMap.reset();
+  }
+};
+
+TEST_F(DnsBpfHelperTest, IsUidNetworkingBlocked) {
+  struct TestConfig {
+    const uid_t uid;
+    const uint32_t enabledRules;
+    const uint32_t uidRules;
+    const int expectedResult;
+    std::string toString() const {
+      return fmt::format(
+          "uid: {}, enabledRules: {}, uidRules: {}, expectedResult: {}",
+          uid, enabledRules, uidRules, expectedResult);
+    }
+  } testConfigs[] = {
+    // clang-format off
+    //   No rule enabled:
+    // uid,         enabledRules,                  uidRules,                      expectedResult
+    {AID_APP_START, NO_MATCH,                      NO_MATCH,                      false},
+
+    //   An allowlist rule:
+    {AID_APP_START, NO_MATCH,                      DOZABLE_MATCH,                 false},
+    {AID_APP_START, DOZABLE_MATCH,                 NO_MATCH,                      true},
+    {AID_APP_START, DOZABLE_MATCH,                 DOZABLE_MATCH,                 false},
+    //   A denylist rule
+    {AID_APP_START, NO_MATCH,                      STANDBY_MATCH,                 false},
+    {AID_APP_START, STANDBY_MATCH,                 NO_MATCH,                      false},
+    {AID_APP_START, STANDBY_MATCH,                 STANDBY_MATCH,                 true},
+
+    //   Multiple rules enabled:
+    //     Match only part of the enabled allowlist rules.
+    {AID_APP_START, DOZABLE_MATCH|POWERSAVE_MATCH, DOZABLE_MATCH,                 true},
+    {AID_APP_START, DOZABLE_MATCH|POWERSAVE_MATCH, POWERSAVE_MATCH,               true},
+    //     Match all of the enabled allowlist rules.
+    {AID_APP_START, DOZABLE_MATCH|POWERSAVE_MATCH, DOZABLE_MATCH|POWERSAVE_MATCH, false},
+    //     Match allowlist.
+    {AID_APP_START, DOZABLE_MATCH|STANDBY_MATCH,   DOZABLE_MATCH,                 false},
+    //     Match no rule.
+    {AID_APP_START, DOZABLE_MATCH|STANDBY_MATCH,   NO_MATCH,                      true},
+    {AID_APP_START, DOZABLE_MATCH|POWERSAVE_MATCH, NO_MATCH,                      true},
+
+    // System UID: always unblocked.
+    {AID_SYSTEM,    NO_MATCH,                      NO_MATCH,                      false},
+    {AID_SYSTEM,    NO_MATCH,                      DOZABLE_MATCH,                 false},
+    {AID_SYSTEM,    DOZABLE_MATCH,                 NO_MATCH,                      false},
+    {AID_SYSTEM,    DOZABLE_MATCH,                 DOZABLE_MATCH,                 false},
+    {AID_SYSTEM,    NO_MATCH,                      STANDBY_MATCH,                 false},
+    {AID_SYSTEM,    STANDBY_MATCH,                 NO_MATCH,                      false},
+    {AID_SYSTEM,    STANDBY_MATCH,                 STANDBY_MATCH,                 false},
+    {AID_SYSTEM,    DOZABLE_MATCH|POWERSAVE_MATCH, DOZABLE_MATCH,                 false},
+    {AID_SYSTEM,    DOZABLE_MATCH|POWERSAVE_MATCH, POWERSAVE_MATCH,               false},
+    {AID_SYSTEM,    DOZABLE_MATCH|POWERSAVE_MATCH, DOZABLE_MATCH|POWERSAVE_MATCH, false},
+    {AID_SYSTEM,    DOZABLE_MATCH|STANDBY_MATCH,   DOZABLE_MATCH,                 false},
+    {AID_SYSTEM,    DOZABLE_MATCH|STANDBY_MATCH,   NO_MATCH,                      false},
+    {AID_SYSTEM,    DOZABLE_MATCH|POWERSAVE_MATCH, NO_MATCH,                      false},
+    // clang-format on
+  };
+
+  for (const auto& config : testConfigs) {
+    SCOPED_TRACE(config.toString());
+
+    // Setup maps.
+    EXPECT_RESULT_OK(mFakeConfigurationMap.writeValue(UID_RULES_CONFIGURATION_KEY,
+                                                      config.enabledRules, BPF_EXIST));
+    EXPECT_RESULT_OK(mFakeUidOwnerMap.writeValue(config.uid, {.iif = 0, .rule = config.uidRules},
+                                                 BPF_ANY));
+
+    // Verify the function.
+    auto result = mDnsBpfHelper.isUidNetworkingBlocked(config.uid, /*metered=*/false);
+    EXPECT_TRUE(result.ok());
+    EXPECT_EQ(config.expectedResult, result.value());
+  }
+}
+
+TEST_F(DnsBpfHelperTest, IsUidNetworkingBlocked_uninitialized) {
+  ResetAllMaps();
+
+  auto result = mDnsBpfHelper.isUidNetworkingBlocked(AID_APP_START, /*metered=*/false);
+  EXPECT_FALSE(result.ok());
+  EXPECT_EQ(EUNATCH, result.error().code());
+
+  result = mDnsBpfHelper.isUidNetworkingBlocked(AID_SYSTEM, /*metered=*/false);
+  EXPECT_TRUE(result.ok());
+  EXPECT_FALSE(result.value());
+}
+
+// Verify DataSaver on metered network.
+TEST_F(DnsBpfHelperTest, IsUidNetworkingBlocked_metered) {
+  struct TestConfig {
+    const uint32_t enabledRules;     // Settings in configuration map.
+    const bool dataSaverEnabled;     // Settings in data saver enabled map.
+    const uint32_t uidRules;         // Settings in uid owner map.
+    const int blocked;               // Whether the UID is expected to be networking blocked or not.
+    std::string toString() const {
+      return fmt::format(
+          ", enabledRules: {}, dataSaverEnabled: {},  uidRules: {}, expect blocked: {}",
+          enabledRules, dataSaverEnabled, uidRules, blocked);
+    }
+  } testConfigs[]{
+    // clang-format off
+    // enabledRules, dataSaverEnabled, uidRules,                                        blocked
+    {NO_MATCH,       false,            NO_MATCH,                                        false},
+    {NO_MATCH,       false,            PENALTY_BOX_MATCH,                               true},
+    {NO_MATCH,       false,            HAPPY_BOX_MATCH,                                 false},
+    {NO_MATCH,       false,            PENALTY_BOX_MATCH|HAPPY_BOX_MATCH,               true},
+    {NO_MATCH,       true,             NO_MATCH,                                        true},
+    {NO_MATCH,       true,             PENALTY_BOX_MATCH,                               true},
+    {NO_MATCH,       true,             HAPPY_BOX_MATCH,                                 false},
+    {NO_MATCH,       true,             PENALTY_BOX_MATCH|HAPPY_BOX_MATCH,               true},
+    {STANDBY_MATCH,  false,            STANDBY_MATCH,                                   true},
+    {STANDBY_MATCH,  false,            STANDBY_MATCH|PENALTY_BOX_MATCH,                 true},
+    {STANDBY_MATCH,  false,            STANDBY_MATCH|HAPPY_BOX_MATCH,                   true},
+    {STANDBY_MATCH,  false,            STANDBY_MATCH|PENALTY_BOX_MATCH|HAPPY_BOX_MATCH, true},
+    {STANDBY_MATCH,  true,             STANDBY_MATCH,                                   true},
+    {STANDBY_MATCH,  true,             STANDBY_MATCH|PENALTY_BOX_MATCH,                 true},
+    {STANDBY_MATCH,  true,             STANDBY_MATCH|HAPPY_BOX_MATCH,                   true},
+    {STANDBY_MATCH,  true,             STANDBY_MATCH|PENALTY_BOX_MATCH|HAPPY_BOX_MATCH, true},
+    // clang-format on
+  };
+
+  for (const auto& config : testConfigs) {
+    SCOPED_TRACE(config.toString());
+
+    // Setup maps.
+    EXPECT_RESULT_OK(mFakeConfigurationMap.writeValue(UID_RULES_CONFIGURATION_KEY,
+                                                      config.enabledRules, BPF_EXIST));
+    EXPECT_RESULT_OK(mFakeDataSaverEnabledMap.writeValue(DATA_SAVER_ENABLED_KEY,
+                                                      config.dataSaverEnabled, BPF_EXIST));
+    EXPECT_RESULT_OK(mFakeUidOwnerMap.writeValue(AID_APP_START, {.iif = 0, .rule = config.uidRules},
+                                                 BPF_ANY));
+
+    // Verify the function.
+    auto result = mDnsBpfHelper.isUidNetworkingBlocked(AID_APP_START, /*metered=*/true);
+    EXPECT_RESULT_OK(result);
+    EXPECT_EQ(config.blocked, result.value());
+  }
+}
+
+}  // namespace net
+}  // namespace android
diff --git a/DnsResolver/DnsHelper.cpp b/DnsResolver/DnsHelper.cpp
new file mode 100644
index 0000000..3372908
--- /dev/null
+++ b/DnsResolver/DnsHelper.cpp
@@ -0,0 +1,37 @@
+/*
+ * 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.
+ */
+
+#include <errno.h>
+
+#include "DnsBpfHelper.h"
+#include "DnsHelperPublic.h"
+
+static android::net::DnsBpfHelper sDnsBpfHelper;
+
+int ADnsHelper_init() {
+  auto result = sDnsBpfHelper.init();
+  if (!result.ok()) return -result.error().code();
+
+  return 0;
+}
+
+int ADnsHelper_isUidNetworkingBlocked(uid_t uid, bool metered) {
+  auto result = sDnsBpfHelper.isUidNetworkingBlocked(uid, metered);
+  if (!result.ok()) return -result.error().code();
+
+  // bool -> int conversion.
+  return result.value();
+}
diff --git a/DnsResolver/include/DnsHelperPublic.h b/DnsResolver/include/DnsHelperPublic.h
new file mode 100644
index 0000000..7c9fc9e
--- /dev/null
+++ b/DnsResolver/include/DnsHelperPublic.h
@@ -0,0 +1,43 @@
+/*
+ * 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.
+ */
+
+#pragma once
+
+#include <sys/cdefs.h>
+#include <sys/types.h>
+
+__BEGIN_DECLS
+
+/*
+ * Perform any required initialization - including opening any required BPF maps. This function
+ * needs to be called before using other functions of this library.
+ *
+ * Returns 0 on success, a negative POSIX error code (see errno.h) on other failures.
+ */
+int ADnsHelper_init();
+
+/*
+ * The function reads bpf maps and returns whether the given uid has blocked networking or not. The
+ * function is supported starting from Android T.
+ *
+ * |uid| is a Linux/Android UID to be queried. It is a combination of UserID and AppID.
+ * |metered| indicates whether the uid is currently using a billing network.
+ *
+ * Returns 0(false)/1(true) on success, a negative POSIX error code (see errno.h) on other failures.
+ */
+int ADnsHelper_isUidNetworkingBlocked(uid_t uid, bool metered);
+
+__END_DECLS
diff --git a/DnsResolver/libcom.android.tethering.dns_helper.map.txt b/DnsResolver/libcom.android.tethering.dns_helper.map.txt
new file mode 100644
index 0000000..3c965a2
--- /dev/null
+++ b/DnsResolver/libcom.android.tethering.dns_helper.map.txt
@@ -0,0 +1,27 @@
+#
+# 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.
+#
+
+# This lists the entry points visible to applications that use the
+# libcom.android.tethering.dns_helper library. Other entry points present in
+# the library won't be usable.
+
+LIBCOM_ANDROID_TETHERING_DNS_HELPER {
+  global:
+    ADnsHelper_init; # apex
+    ADnsHelper_isUidNetworkingBlocked; # apex
+  local:
+    *;
+};
diff --git a/OWNERS b/OWNERS
index 649efda..b2176cc 100644
--- a/OWNERS
+++ b/OWNERS
@@ -1,3 +1,4 @@
+# Bug component: 31808
 set noparent
 file:platform/packages/modules/Connectivity:main:/OWNERS_core_networking
 
diff --git a/TEST_MAPPING b/TEST_MAPPING
index d33453c..520124d 100644
--- a/TEST_MAPPING
+++ b/TEST_MAPPING
@@ -1,7 +1,15 @@
 {
   "presubmit": [
     {
-      "name": "ConnectivityCoverageTests"
+      "name": "ConnectivityCoverageTests",
+      "options": [
+        {
+          "exclude-annotation": "com.android.testutils.NetworkStackModuleTest"
+        },
+        {
+          "exclude-annotation": "com.android.testutils.SkipPresubmit"
+        }
+      ]
     },
     {
       // In addition to ConnectivityCoverageTests, runs non-connectivity-module tests
@@ -123,6 +131,9 @@
       "keywords": ["netd-device-kernel-4.9", "netd-device-kernel-4.14"]
     },
     {
+      "name": "dns_helper_unit_test"
+    },
+    {
       "name": "traffic_controller_unit_test",
       "keywords": ["netd-device-kernel-4.9", "netd-device-kernel-4.14"]
     },
@@ -255,7 +266,12 @@
       "name": "netd_updatable_unit_test[CaptivePortalLoginGoogle.apk+NetworkStackGoogle.apk+com.google.android.resolv.apex+com.google.android.tethering.apex]"
     },
     {
-      "name": "ConnectivityCoverageTests[CaptivePortalLoginGoogle.apk+NetworkStackGoogle.apk+com.google.android.resolv.apex+com.google.android.tethering.apex]"
+      "name": "ConnectivityCoverageTests[CaptivePortalLoginGoogle.apk+NetworkStackGoogle.apk+com.google.android.resolv.apex+com.google.android.tethering.apex]",
+      "options": [
+        {
+          "exclude-annotation": "com.android.testutils.SkipPresubmit"
+        }
+      ]
     },
     {
       "name": "traffic_controller_unit_test[CaptivePortalLoginGoogle.apk+NetworkStackGoogle.apk+com.google.android.resolv.apex+com.google.android.tethering.apex]"
diff --git a/Tethering/Android.bp b/Tethering/Android.bp
index 4478b1e..dd60be7 100644
--- a/Tethering/Android.bp
+++ b/Tethering/Android.bp
@@ -95,6 +95,7 @@
     ],
     static_libs: [
         "NetworkStackApiCurrentShims",
+        "net-utils-device-common-struct",
     ],
     apex_available: ["com.android.tethering"],
     lint: { strict_updatability_linting: true },
@@ -109,6 +110,7 @@
     ],
     static_libs: [
         "NetworkStackApiStableShims",
+        "net-utils-device-common-struct",
     ],
     apex_available: ["com.android.tethering"],
     lint: { strict_updatability_linting: true },
@@ -224,6 +226,7 @@
         "com.android.tethering",
     ],
     native_shared_libs: [
+        "libcom.android.tethering.dns_helper",
         "libnetd_updatable",
     ],
 }
diff --git a/Tethering/apex/Android.bp b/Tethering/apex/Android.bp
index 9757daa..ee44f3c 100644
--- a/Tethering/apex/Android.bp
+++ b/Tethering/apex/Android.bp
@@ -83,6 +83,7 @@
                 "libandroid_net_connectivity_com_android_net_module_util_jni",
             ],
             native_shared_libs: [
+                "libcom.android.tethering.dns_helper",
                 "libcom.android.tethering.connectivity_native",
                 "libnetd_updatable",
             ],
@@ -96,6 +97,8 @@
     },
     binaries: [
         "clatd",
+        "ethtool",
+        "netbpfload",
         "ot-daemon",
     ],
     canned_fs_config: "canned_fs_config",
diff --git a/Tethering/apex/canned_fs_config b/Tethering/apex/canned_fs_config
index 5a03347..1f5fcfa 100644
--- a/Tethering/apex/canned_fs_config
+++ b/Tethering/apex/canned_fs_config
@@ -1,2 +1,3 @@
 /bin/for-system 0 1000 0750
 /bin/for-system/clatd 1029 1029 06755
+/bin/netbpfload 0 0 0750
diff --git a/Tethering/apishim/31/com/android/networkstack/tethering/apishim/api31/BpfCoordinatorShimImpl.java b/Tethering/apishim/31/com/android/networkstack/tethering/apishim/api31/BpfCoordinatorShimImpl.java
index a280046..4d1e7ef 100644
--- a/Tethering/apishim/31/com/android/networkstack/tethering/apishim/api31/BpfCoordinatorShimImpl.java
+++ b/Tethering/apishim/31/com/android/networkstack/tethering/apishim/api31/BpfCoordinatorShimImpl.java
@@ -167,7 +167,6 @@
 
     @Override
     public boolean addIpv6UpstreamRule(@NonNull final Ipv6UpstreamRule rule) {
-        if (!isInitialized()) return false;
         // RFC7421_PREFIX_LENGTH = 64 which is the most commonly used IPv6 subnet prefix length.
         if (rule.sourcePrefix.getPrefixLength() != RFC7421_PREFIX_LENGTH) return false;
 
@@ -185,7 +184,6 @@
 
     @Override
     public boolean removeIpv6UpstreamRule(@NonNull final Ipv6UpstreamRule rule) {
-        if (!isInitialized()) return false;
         // RFC7421_PREFIX_LENGTH = 64 which is the most commonly used IPv6 subnet prefix length.
         if (rule.sourcePrefix.getPrefixLength() != RFC7421_PREFIX_LENGTH) return false;
 
@@ -200,8 +198,6 @@
 
     @Override
     public boolean addIpv6DownstreamRule(@NonNull final Ipv6DownstreamRule rule) {
-        if (!isInitialized()) return false;
-
         final TetherDownstream6Key key = rule.makeTetherDownstream6Key();
         final Tether6Value value = rule.makeTether6Value();
 
@@ -217,8 +213,6 @@
 
     @Override
     public boolean removeIpv6DownstreamRule(@NonNull final Ipv6DownstreamRule rule) {
-        if (!isInitialized()) return false;
-
         try {
             mBpfDownstream6Map.deleteEntry(rule.makeTetherDownstream6Key());
         } catch (ErrnoException e) {
@@ -234,8 +228,6 @@
     @Override
     @Nullable
     public SparseArray<TetherStatsValue> tetherOffloadGetStats() {
-        if (!isInitialized()) return null;
-
         final SparseArray<TetherStatsValue> tetherStatsList = new SparseArray<TetherStatsValue>();
         try {
             // The reported tether stats are total data usage for all currently-active upstream
@@ -250,8 +242,6 @@
 
     @Override
     public boolean tetherOffloadSetInterfaceQuota(int ifIndex, long quotaBytes) {
-        if (!isInitialized()) return false;
-
         // The common case is an update, where the stats already exist,
         // hence we read first, even though writing with BPF_NOEXIST
         // first would make the code simpler.
@@ -307,8 +297,6 @@
     @Override
     @Nullable
     public TetherStatsValue tetherOffloadGetAndClearStats(int ifIndex) {
-        if (!isInitialized()) return null;
-
         // getAndClearTetherOffloadStats is called after all offload rules have already been
         // deleted for the given upstream interface. Before starting to do cleanup stuff in this
         // function, use synchronizeKernelRCU to make sure that all the current running eBPF
@@ -354,8 +342,6 @@
     @Override
     public boolean tetherOffloadRuleAdd(boolean downstream, @NonNull Tether4Key key,
             @NonNull Tether4Value value) {
-        if (!isInitialized()) return false;
-
         try {
             if (downstream) {
                 mBpfDownstream4Map.insertEntry(key, value);
@@ -379,8 +365,6 @@
 
     @Override
     public boolean tetherOffloadRuleRemove(boolean downstream, @NonNull Tether4Key key) {
-        if (!isInitialized()) return false;
-
         try {
             if (downstream) {
                 if (!mBpfDownstream4Map.deleteEntry(key)) return false;  // Rule did not exist
@@ -413,8 +397,6 @@
     @Override
     public void tetherOffloadRuleForEach(boolean downstream,
             @NonNull ThrowingBiConsumer<Tether4Key, Tether4Value> action) {
-        if (!isInitialized()) return;
-
         try {
             if (downstream) {
                 mBpfDownstream4Map.forEach(action);
@@ -428,8 +410,6 @@
 
     @Override
     public boolean attachProgram(String iface, boolean downstream, boolean ipv4) {
-        if (!isInitialized()) return false;
-
         try {
             BpfUtils.attachProgram(iface, downstream, ipv4);
         } catch (IOException e) {
@@ -441,8 +421,6 @@
 
     @Override
     public boolean detachProgram(String iface, boolean ipv4) {
-        if (!isInitialized()) return false;
-
         try {
             BpfUtils.detachProgram(iface, ipv4);
         } catch (IOException e) {
@@ -460,8 +438,6 @@
 
     @Override
     public boolean addDevMap(int ifIndex) {
-        if (!isInitialized()) return false;
-
         try {
             mBpfDevMap.updateEntry(new TetherDevKey(ifIndex), new TetherDevValue(ifIndex));
         } catch (ErrnoException e) {
@@ -473,8 +449,6 @@
 
     @Override
     public boolean removeDevMap(int ifIndex) {
-        if (!isInitialized()) return false;
-
         try {
             mBpfDevMap.deleteEntry(new TetherDevKey(ifIndex));
         } catch (ErrnoException e) {
diff --git a/Tethering/lint-baseline.xml b/Tethering/lint-baseline.xml
new file mode 100644
index 0000000..37511c6
--- /dev/null
+++ b/Tethering/lint-baseline.xml
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<issues format="6" by="lint 8.0.0-dev" type="baseline" dependencies="true" variant="all" version="8.0.0-dev">
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `android.net.netstats.provider.NetworkStatsProvider#notifyWarningReached`"
+        errorLine1="                            mStatsProvider.notifyWarningReached();"
+        errorLine2="                                           ~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/Tethering/src/com/android/networkstack/tethering/OffloadController.java"
+            line="293"
+            column="44"/>
+    </issue>
+
+</issues>
\ No newline at end of file
diff --git a/Tethering/src/android/net/ip/IpServer.java b/Tethering/src/android/net/ip/IpServer.java
index a851410..79d9a23 100644
--- a/Tethering/src/android/net/ip/IpServer.java
+++ b/Tethering/src/android/net/ip/IpServer.java
@@ -33,6 +33,7 @@
 
 import static com.android.net.module.util.Inet4AddressUtils.intToInet4AddressHTH;
 import static com.android.net.module.util.NetworkStackConstants.RFC7421_PREFIX_LENGTH;
+import static com.android.networkstack.tethering.TetheringConfiguration.USE_SYNC_SM;
 import static com.android.networkstack.tethering.UpstreamNetworkState.isVcnInterface;
 import static com.android.networkstack.tethering.util.PrefixUtils.asIpPrefix;
 import static com.android.networkstack.tethering.util.TetheringMessageBase.BASE_IPSERVER;
@@ -44,6 +45,7 @@
 import android.net.LinkProperties;
 import android.net.MacAddress;
 import android.net.RouteInfo;
+import android.net.RoutingCoordinatorManager;
 import android.net.TetheredClient;
 import android.net.TetheringManager;
 import android.net.TetheringRequestParcel;
@@ -55,7 +57,6 @@
 import android.net.dhcp.IDhcpServer;
 import android.net.ip.RouterAdvertisementDaemon.RaParams;
 import android.os.Handler;
-import android.os.Looper;
 import android.os.Message;
 import android.os.RemoteException;
 import android.os.ServiceSpecificException;
@@ -65,12 +66,13 @@
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 
+import com.android.internal.annotations.VisibleForTesting;
 import com.android.internal.util.MessageUtils;
 import com.android.internal.util.State;
-import com.android.internal.util.StateMachine;
 import com.android.modules.utils.build.SdkLevel;
 import com.android.net.module.util.InterfaceParams;
 import com.android.net.module.util.NetdUtils;
+import com.android.net.module.util.SdkUtil.LateSdk;
 import com.android.net.module.util.SharedLog;
 import com.android.net.module.util.ip.InterfaceController;
 import com.android.net.module.util.ip.IpNeighborMonitor;
@@ -83,12 +85,15 @@
 import com.android.networkstack.tethering.metrics.TetheringMetrics;
 import com.android.networkstack.tethering.util.InterfaceSet;
 import com.android.networkstack.tethering.util.PrefixUtils;
+import com.android.networkstack.tethering.util.StateMachineShim;
+import com.android.networkstack.tethering.util.SyncStateMachine.StateInfo;
 
 import java.net.Inet4Address;
 import java.net.Inet6Address;
 import java.net.UnknownHostException;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.HashSet;
 import java.util.List;
@@ -102,7 +107,7 @@
  *
  * @hide
  */
-public class IpServer extends StateMachine {
+public class IpServer extends StateMachineShim {
     public static final int STATE_UNAVAILABLE = 0;
     public static final int STATE_AVAILABLE   = 1;
     public static final int STATE_TETHERED    = 2;
@@ -127,6 +132,7 @@
     // TODO: have this configurable
     private static final int DHCP_LEASE_TIME_SECS = 3600;
 
+    private static final int NO_UPSTREAM = 0;
     private static final MacAddress NULL_MAC_ADDRESS = MacAddress.fromString("00:00:00:00:00:00");
 
     private static final String TAG = "IpServer";
@@ -233,6 +239,7 @@
     public static final int CMD_NEW_PREFIX_REQUEST          = BASE_IPSERVER + 12;
     // request from PrivateAddressCoordinator to restart tethering.
     public static final int CMD_NOTIFY_PREFIX_CONFLICT      = BASE_IPSERVER + 13;
+    public static final int CMD_SERVICE_FAILED_TO_START     = BASE_IPSERVER + 14;
 
     private final State mInitialState;
     private final State mLocalHotspotState;
@@ -244,6 +251,11 @@
     private final INetd mNetd;
     @NonNull
     private final BpfCoordinator mBpfCoordinator;
+    // Contains null if the connectivity module is unsupported, as the routing coordinator is not
+    // available. Must use LateSdk because MessageUtils enumerates fields in this class, so it
+    // must be able to find all classes at runtime.
+    @NonNull
+    private final LateSdk<RoutingCoordinatorManager> mRoutingCoordinator;
     private final Callback mCallback;
     private final InterfaceController mInterfaceCtrl;
     private final PrivateAddressCoordinator mPrivateAddressCoordinator;
@@ -259,6 +271,12 @@
     private int mLastError;
     private int mServingMode;
     private InterfaceSet mUpstreamIfaceSet;  // may change over time
+    // mInterfaceParams can't be final now because IpServer will be created when receives
+    // WIFI_AP_STATE_CHANGED broadcasts or when it detects that the wifi interface has come up.
+    // In the latter case, the interface is not fully initialized and the MAC address might not
+    // be correct (it will be set with a randomized MAC address later).
+    // TODO: Consider create the IpServer only when tethering want to enable it, then we can
+    //       make mInterfaceParams final.
     private InterfaceParams mInterfaceParams;
     // TODO: De-duplicate this with mLinkProperties above. Currently, these link
     // properties are those selected by the IPv6TetheringCoordinator and relayed
@@ -283,6 +301,8 @@
 
     private int mLastIPv6UpstreamIfindex = 0;
     private boolean mUpstreamSupportsBpf = false;
+    @NonNull
+    private Set<IpPrefix> mLastIPv6UpstreamPrefixes = Collections.emptySet();
 
     private class MyNeighborEventConsumer implements IpNeighborMonitor.NeighborEventConsumer {
         public void accept(NeighborEvent e) {
@@ -295,18 +315,22 @@
     private LinkAddress mIpv4Address;
 
     private final TetheringMetrics mTetheringMetrics;
+    private final Handler mHandler;
 
     // TODO: Add a dependency object to pass the data members or variables from the tethering
     // object. It helps to reduce the arguments of the constructor.
     public IpServer(
-            String ifaceName, Looper looper, int interfaceType, SharedLog log,
-            INetd netd, @NonNull BpfCoordinator coordinator, Callback callback,
+            String ifaceName, Handler handler, int interfaceType, SharedLog log,
+            INetd netd, @NonNull BpfCoordinator bpfCoordinator,
+            @Nullable LateSdk<RoutingCoordinatorManager> routingCoordinator, Callback callback,
             TetheringConfiguration config, PrivateAddressCoordinator addressCoordinator,
             TetheringMetrics tetheringMetrics, Dependencies deps) {
-        super(ifaceName, looper);
+        super(ifaceName, USE_SYNC_SM ? null : handler.getLooper());
+        mHandler = handler;
         mLog = log.forSubComponent(ifaceName);
         mNetd = netd;
-        mBpfCoordinator = coordinator;
+        mBpfCoordinator = bpfCoordinator;
+        mRoutingCoordinator = routingCoordinator;
         mCallback = callback;
         mInterfaceCtrl = new InterfaceController(ifaceName, mNetd, mLog);
         mIfaceName = ifaceName;
@@ -335,13 +359,22 @@
         mTetheredState = new TetheredState();
         mUnavailableState = new UnavailableState();
         mWaitingForRestartState = new WaitingForRestartState();
-        addState(mInitialState);
-        addState(mLocalHotspotState);
-        addState(mTetheredState);
-        addState(mWaitingForRestartState, mTetheredState);
-        addState(mUnavailableState);
+        final ArrayList allStates = new ArrayList<StateInfo>();
+        allStates.add(new StateInfo(mInitialState, null));
+        allStates.add(new StateInfo(mLocalHotspotState, null));
+        allStates.add(new StateInfo(mTetheredState, null));
+        allStates.add(new StateInfo(mWaitingForRestartState, mTetheredState));
+        allStates.add(new StateInfo(mUnavailableState, null));
+        addAllStates(allStates);
+    }
 
-        setInitialState(mInitialState);
+    private Handler getHandler() {
+        return mHandler;
+    }
+
+    /** Start IpServer state machine. */
+    public void start() {
+        start(mInitialState);
     }
 
     /** Interface name which IpServer served.*/
@@ -482,7 +515,12 @@
 
         private void handleError() {
             mLastError = TETHER_ERROR_DHCPSERVER_ERROR;
-            transitionTo(mInitialState);
+            if (USE_SYNC_SM) {
+                sendMessage(CMD_SERVICE_FAILED_TO_START, TETHER_ERROR_DHCPSERVER_ERROR);
+            } else {
+                sendMessageAtFrontOfQueueToAsyncSM(CMD_SERVICE_FAILED_TO_START,
+                        TETHER_ERROR_DHCPSERVER_ERROR);
+            }
         }
     }
 
@@ -740,7 +778,7 @@
         RaParams params = null;
         String upstreamIface = null;
         InterfaceParams upstreamIfaceParams = null;
-        int upstreamIfIndex = 0;
+        int upstreamIfIndex = NO_UPSTREAM;
 
         if (v6only != null) {
             upstreamIface = v6only.getInterfaceName();
@@ -754,13 +792,8 @@
 
             if (params.hasDefaultRoute) params.hopLimit = getHopLimit(upstreamIface, ttlAdjustment);
 
-            for (LinkAddress linkAddr : v6only.getLinkAddresses()) {
-                if (linkAddr.getPrefixLength() != RFC7421_PREFIX_LENGTH) continue;
-
-                final IpPrefix prefix = new IpPrefix(
-                        linkAddr.getAddress(), linkAddr.getPrefixLength());
-                params.prefixes.add(prefix);
-
+            params.prefixes = getTetherableIpv6Prefixes(v6only);
+            for (IpPrefix prefix : params.prefixes) {
                 final Inet6Address dnsServer = getLocalDnsIpFor(prefix);
                 if (dnsServer != null) {
                     params.dnses.add(dnsServer);
@@ -772,7 +805,7 @@
         // CMD_TETHER_CONNECTION_CHANGED. Adding the mapping update here to the avoid potential
         // timing issue. It prevents that the IPv6 capability is updated later than
         // CMD_TETHER_CONNECTION_CHANGED.
-        mBpfCoordinator.addUpstreamNameToLookupTable(upstreamIfIndex, upstreamIface);
+        mBpfCoordinator.maybeAddUpstreamToLookupTable(upstreamIfIndex, upstreamIface);
 
         // If v6only is null, we pass in null to setRaParams(), which handles
         // deprecation of any existing RA data.
@@ -780,10 +813,12 @@
 
         // Not support BPF on virtual upstream interface
         final boolean upstreamSupportsBpf = upstreamIface != null && !isVcnInterface(upstreamIface);
-        updateIpv6ForwardingRules(
-                mLastIPv6UpstreamIfindex, upstreamIfIndex, upstreamSupportsBpf, null);
+        final Set<IpPrefix> upstreamPrefixes = params != null ? params.prefixes : Set.of();
+        updateIpv6ForwardingRules(mLastIPv6UpstreamIfindex, mLastIPv6UpstreamPrefixes,
+                upstreamIfIndex, upstreamPrefixes, upstreamSupportsBpf);
         mLastIPv6LinkProperties = v6only;
         mLastIPv6UpstreamIfindex = upstreamIfIndex;
+        mLastIPv6UpstreamPrefixes = upstreamPrefixes;
         mUpstreamSupportsBpf = upstreamSupportsBpf;
         if (mDadProxy != null) {
             mDadProxy.setUpstreamIface(upstreamIfaceParams);
@@ -801,21 +836,62 @@
         for (RouteInfo route : toBeRemoved) mLinkProperties.removeRoute(route);
     }
 
-    private void addRoutesToLocalNetwork(@NonNull final List<RouteInfo> toBeAdded) {
+    private void addInterfaceToNetwork(final int netId, @NonNull final String ifaceName) {
         try {
-            // It's safe to call networkAddInterface() even if
-            // the interface is already in the local_network.
-            mNetd.networkAddInterface(INetd.LOCAL_NET_ID, mIfaceName);
-            try {
-                // Add routes from local network. Note that adding routes that
-                // already exist does not cause an error (EEXIST is silently ignored).
-                NetdUtils.addRoutesToLocalNetwork(mNetd, mIfaceName, toBeAdded);
-            } catch (IllegalStateException e) {
-                mLog.e("Failed to add IPv4/v6 routes to local table: " + e);
-                return;
+            if (null != mRoutingCoordinator.value) {
+                // TODO : remove this call in favor of using the LocalNetworkConfiguration
+                // correctly, which will let ConnectivityService do it automatically.
+                mRoutingCoordinator.value.addInterfaceToNetwork(netId, ifaceName);
+            } else {
+                mNetd.networkAddInterface(netId, ifaceName);
             }
         } catch (ServiceSpecificException | RemoteException e) {
             mLog.e("Failed to add " + mIfaceName + " to local table: ", e);
+        }
+    }
+
+    private void addInterfaceForward(@NonNull final String fromIface,
+            @NonNull final String toIface) throws ServiceSpecificException, RemoteException {
+        if (null != mRoutingCoordinator.value) {
+            mRoutingCoordinator.value.addInterfaceForward(fromIface, toIface);
+        } else {
+            mNetd.tetherAddForward(fromIface, toIface);
+            mNetd.ipfwdAddInterfaceForward(fromIface, toIface);
+        }
+    }
+
+    private void removeInterfaceForward(@NonNull final String fromIface,
+            @NonNull final String toIface) {
+        if (null != mRoutingCoordinator.value) {
+            try {
+                mRoutingCoordinator.value.removeInterfaceForward(fromIface, toIface);
+            } catch (ServiceSpecificException e) {
+                mLog.e("Exception in removeInterfaceForward", e);
+            }
+        } else {
+            try {
+                mNetd.ipfwdRemoveInterfaceForward(fromIface, toIface);
+            } catch (RemoteException | ServiceSpecificException e) {
+                mLog.e("Exception in ipfwdRemoveInterfaceForward", e);
+            }
+            try {
+                mNetd.tetherRemoveForward(fromIface, toIface);
+            } catch (RemoteException | ServiceSpecificException e) {
+                mLog.e("Exception in disableNat", e);
+            }
+        }
+    }
+
+    private void addRoutesToLocalNetwork(@NonNull final List<RouteInfo> toBeAdded) {
+        // It's safe to call addInterfaceToNetwork() even if
+        // the interface is already in the local_network.
+        addInterfaceToNetwork(INetd.LOCAL_NET_ID, mIfaceName);
+        try {
+            // Add routes from local network. Note that adding routes that
+            // already exist does not cause an error (EEXIST is silently ignored).
+            NetdUtils.addRoutesToLocalNetwork(mNetd, mIfaceName, toBeAdded);
+        } catch (IllegalStateException e) {
+            mLog.e("Failed to add IPv4/v6 routes to local table: " + e);
             return;
         }
 
@@ -887,25 +963,26 @@
         }
     }
 
-    // Handles all updates to IPv6 forwarding rules. These can currently change only if the upstream
-    // changes or if a neighbor event is received.
-    private void updateIpv6ForwardingRules(int prevUpstreamIfindex, int upstreamIfindex,
-            boolean upstreamSupportsBpf, NeighborEvent e) {
-        // If no longer have an upstream or upstream not supports BPF, clear forwarding rules and do
-        // nothing else.
-        // TODO: Rather than always clear rules, ensure whether ipv6 ever enable first.
-        if (upstreamIfindex == 0 || !upstreamSupportsBpf) {
-            mBpfCoordinator.tetherOffloadRuleClear(this);
-            return;
-        }
+    private int getInterfaceIndexForRule(int ifindex, boolean supportsBpf) {
+        return supportsBpf ? ifindex : NO_UPSTREAM;
+    }
 
+    // Handles updates to IPv6 forwarding rules if the upstream or its prefixes change.
+    private void updateIpv6ForwardingRules(int prevUpstreamIfindex,
+            @NonNull Set<IpPrefix> prevUpstreamPrefixes, int upstreamIfindex,
+            @NonNull Set<IpPrefix> upstreamPrefixes, boolean upstreamSupportsBpf) {
         // If the upstream interface has changed, remove all rules and re-add them with the new
-        // upstream interface.
-        if (prevUpstreamIfindex != upstreamIfindex) {
-            mBpfCoordinator.tetherOffloadRuleUpdate(this, upstreamIfindex);
+        // upstream interface. If upstream is a virtual network, treated as no upstream.
+        if (prevUpstreamIfindex != upstreamIfindex
+                || !prevUpstreamPrefixes.equals(upstreamPrefixes)) {
+            mBpfCoordinator.updateAllIpv6Rules(this, this.mInterfaceParams,
+                    getInterfaceIndexForRule(upstreamIfindex, upstreamSupportsBpf),
+                    upstreamPrefixes);
         }
+    }
 
-        // If we're here to process a NeighborEvent, do so now.
+    // Handles updates to IPv6 downstream rules if a neighbor event is received.
+    private void addOrRemoveIpv6Downstream(NeighborEvent e) {
         // mInterfaceParams must be non-null or the event would not have arrived.
         if (e == null) return;
         if (!(e.ip instanceof Inet6Address) || e.ip.isMulticastAddress()
@@ -917,8 +994,9 @@
         // Do this here instead of in the Ipv6DownstreamRule constructor to ensure that we
         // never add rules with a null MAC, only delete them.
         MacAddress dstMac = e.isValid() ? e.macAddr : NULL_MAC_ADDRESS;
-        Ipv6DownstreamRule rule = new Ipv6DownstreamRule(upstreamIfindex, mInterfaceParams.index,
-                (Inet6Address) e.ip, mInterfaceParams.macAddr, dstMac);
+        Ipv6DownstreamRule rule = new Ipv6DownstreamRule(
+                getInterfaceIndexForRule(mLastIPv6UpstreamIfindex, mUpstreamSupportsBpf),
+                mInterfaceParams.index, (Inet6Address) e.ip, mInterfaceParams.macAddr, dstMac);
         if (e.isValid()) {
             mBpfCoordinator.addIpv6DownstreamRule(this, rule);
         } else {
@@ -951,8 +1029,7 @@
         if (mInterfaceParams != null
                 && mInterfaceParams.index == e.ifindex
                 && mInterfaceParams.hasMacAddress) {
-            updateIpv6ForwardingRules(mLastIPv6UpstreamIfindex, mLastIPv6UpstreamIfindex,
-                    mUpstreamSupportsBpf, e);
+            addOrRemoveIpv6Downstream(e);
             updateClientInfoIpv4(e);
         }
     }
@@ -1085,7 +1162,19 @@
             startServingInterface();
 
             if (mLastError != TETHER_ERROR_NO_ERROR) {
-                transitionTo(mInitialState);
+                // This will transition to InitialState right away, regardless of whether any
+                // message is already waiting in the StateMachine queue (including maybe some
+                // message to go to InitialState). InitialState will then process any pending
+                // message (and generally ignores them). It is difficult to know for sure whether
+                // this is correct in all cases, but this is equivalent to what IpServer was doing
+                // in previous versions of the mainline module.
+                // TODO : remove sendMessageAtFrontOfQueueToAsyncSM after migrating to the Sync
+                // StateMachine.
+                if (USE_SYNC_SM) {
+                    sendSelfMessageToSyncSM(CMD_SERVICE_FAILED_TO_START, mLastError);
+                } else {
+                    sendMessageAtFrontOfQueueToAsyncSM(CMD_SERVICE_FAILED_TO_START, mLastError);
+                }
             }
 
             if (DBG) Log.d(TAG, getStateString(mDesiredInterfaceState) + " serve " + mIfaceName);
@@ -1175,6 +1264,9 @@
                     mCallback.requestEnableTethering(mInterfaceType, false /* enabled */);
                     transitionTo(mWaitingForRestartState);
                     break;
+                case CMD_SERVICE_FAILED_TO_START:
+                    mLog.e("start serving fail, error: " + message.arg1);
+                    transitionTo(mInitialState);
                 default:
                     return false;
             }
@@ -1285,6 +1377,7 @@
         @Override
         public void exit() {
             cleanupUpstream();
+            mBpfCoordinator.clearAllIpv6Rules(IpServer.this);
             super.exit();
         }
 
@@ -1303,7 +1396,8 @@
 
             for (String ifname : mUpstreamIfaceSet.ifnames) cleanupUpstreamInterface(ifname);
             mUpstreamIfaceSet = null;
-            mBpfCoordinator.tetherOffloadRuleClear(IpServer.this);
+            mBpfCoordinator.updateAllIpv6Rules(
+                    IpServer.this, IpServer.this.mInterfaceParams, NO_UPSTREAM, Set.of());
         }
 
         private void cleanupUpstreamInterface(String upstreamIface) {
@@ -1312,16 +1406,7 @@
             // to remove their rules, which generates errors.
             // Just do the best we can.
             mBpfCoordinator.maybeDetachProgram(mIfaceName, upstreamIface);
-            try {
-                mNetd.ipfwdRemoveInterfaceForward(mIfaceName, upstreamIface);
-            } catch (RemoteException | ServiceSpecificException e) {
-                mLog.e("Exception in ipfwdRemoveInterfaceForward: " + e.toString());
-            }
-            try {
-                mNetd.tetherRemoveForward(mIfaceName, upstreamIface);
-            } catch (RemoteException | ServiceSpecificException e) {
-                mLog.e("Exception in disableNat: " + e.toString());
-            }
+            removeInterfaceForward(mIfaceName, upstreamIface);
         }
 
         @Override
@@ -1370,17 +1455,16 @@
                             final InterfaceParams upstreamIfaceParams =
                                     mDeps.getInterfaceParams(ifname);
                             if (upstreamIfaceParams != null) {
-                                mBpfCoordinator.addUpstreamNameToLookupTable(
+                                mBpfCoordinator.maybeAddUpstreamToLookupTable(
                                         upstreamIfaceParams.index, ifname);
                             }
                         }
 
                         mBpfCoordinator.maybeAttachProgram(mIfaceName, ifname);
                         try {
-                            mNetd.tetherAddForward(mIfaceName, ifname);
-                            mNetd.ipfwdAddInterfaceForward(mIfaceName, ifname);
+                            addInterfaceForward(mIfaceName, ifname);
                         } catch (RemoteException | ServiceSpecificException e) {
-                            mLog.e("Exception enabling NAT: " + e.toString());
+                            mLog.e("Exception enabling iface forward", e);
                             cleanupUpstream();
                             mLastError = TETHER_ERROR_ENABLE_FORWARDING_ERROR;
                             transitionTo(mInitialState);
@@ -1430,6 +1514,8 @@
     class UnavailableState extends State {
         @Override
         public void enter() {
+            // TODO: move mIpNeighborMonitor.stop() to TetheredState#exit, and trigger a neighbours
+            //       dump after starting mIpNeighborMonitor.
             mIpNeighborMonitor.stop();
             mLastError = TETHER_ERROR_NO_ERROR;
             sendInterfaceState(STATE_UNAVAILABLE);
@@ -1488,4 +1574,21 @@
         }
         return random;
     }
+
+    /** Get IPv6 prefixes from LinkProperties */
+    @NonNull
+    @VisibleForTesting
+    static HashSet<IpPrefix> getTetherableIpv6Prefixes(@NonNull Collection<LinkAddress> addrs) {
+        final HashSet<IpPrefix> prefixes = new HashSet<>();
+        for (LinkAddress linkAddr : addrs) {
+            if (linkAddr.getPrefixLength() != RFC7421_PREFIX_LENGTH) continue;
+            prefixes.add(new IpPrefix(linkAddr.getAddress(), RFC7421_PREFIX_LENGTH));
+        }
+        return prefixes;
+    }
+
+    @NonNull
+    private HashSet<IpPrefix> getTetherableIpv6Prefixes(@NonNull LinkProperties lp) {
+        return getTetherableIpv6Prefixes(lp.getLinkAddresses());
+    }
 }
diff --git a/Tethering/src/com/android/networkstack/tethering/BpfCoordinator.java b/Tethering/src/com/android/networkstack/tethering/BpfCoordinator.java
index 1b23a6c..2b14a42 100644
--- a/Tethering/src/com/android/networkstack/tethering/BpfCoordinator.java
+++ b/Tethering/src/com/android/networkstack/tethering/BpfCoordinator.java
@@ -28,6 +28,7 @@
 import static android.system.OsConstants.ETH_P_IPV6;
 
 import static com.android.net.module.util.NetworkStackConstants.IPV4_MIN_MTU;
+import static com.android.net.module.util.NetworkStackConstants.IPV6_ADDR_LEN;
 import static com.android.net.module.util.ip.ConntrackMonitor.ConntrackEvent;
 import static com.android.networkstack.tethering.BpfUtils.DOWNSTREAM;
 import static com.android.networkstack.tethering.BpfUtils.UPSTREAM;
@@ -50,6 +51,7 @@
 import android.system.ErrnoException;
 import android.system.OsConstants;
 import android.text.TextUtils;
+import android.util.ArrayMap;
 import android.util.ArraySet;
 import android.util.Log;
 import android.util.SparseArray;
@@ -89,7 +91,6 @@
 import java.net.SocketException;
 import java.net.UnknownHostException;
 import java.nio.ByteBuffer;
-import java.nio.ByteOrder;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
@@ -122,7 +123,6 @@
     private static final int DUMP_TIMEOUT_MS = 10_000;
     private static final MacAddress NULL_MAC_ADDRESS = MacAddress.fromString(
             "00:00:00:00:00:00");
-    private static final IpPrefix IPV6_ZERO_PREFIX64 = new IpPrefix("::/64");
     private static final String TETHER_DOWNSTREAM4_MAP_PATH = makeMapPath(DOWNSTREAM, 4);
     private static final String TETHER_UPSTREAM4_MAP_PATH = makeMapPath(UPSTREAM, 4);
     private static final String TETHER_DOWNSTREAM6_FS_PATH = makeMapPath(DOWNSTREAM, 6);
@@ -153,6 +153,7 @@
     static final int NF_CONNTRACK_UDP_TIMEOUT_STREAM = 180;
     @VisibleForTesting
     static final int INVALID_MTU = 0;
+    static final int NO_UPSTREAM = 0;
 
     // List of TCP port numbers which aren't offloaded because the packets require the netfilter
     // conntrack helper. See also TetherController::setForwardRules in netd.
@@ -221,6 +222,23 @@
     // TODO: Remove the unused interface name.
     private final SparseArray<String> mInterfaceNames = new SparseArray<>();
 
+    // How IPv6 upstream rules and downstream rules are managed in BpfCoordinator:
+    // 1. Each upstream rule represents a downstream interface to an upstream interface forwarding.
+    //    No upstream rule will be exist if there is no upstream interface.
+    //    Note that there is at most one upstream interface for a given downstream interface.
+    // 2. Each downstream rule represents an IPv6 neighbor, regardless of the existence of the
+    //    upstream interface. If the upstream is not present, the downstream rules have an upstream
+    //    interface index of NO_UPSTREAM, only exist in BpfCoordinator and won't be written to the
+    //    BPF map. When the upstream comes back, those downstream rules will be updated by calling
+    //    Ipv6DownstreamRule#onNewUpstream and written to the BPF map again. We don't remove the
+    //    downstream rules when upstream is lost is because the upstream may come back with the
+    //    same prefix and we won't receive any neighbor update event in this case.
+    //    TODO: Remove downstream rules when upstream is lost and dump neighbors table when upstream
+    //    interface comes back in order to reconstruct the downstream rules.
+    // 3. It is the same thing for BpfCoordinator if there is no upstream interface or the upstream
+    //    interface is a virtual interface (which currently not supports BPF). In this case,
+    //    IpServer will update its upstream ifindex to NO_UPSTREAM to the BpfCoordinator.
+
     // Map of downstream rule maps. Each of these maps represents the IPv6 forwarding rules for a
     // given downstream. Each map:
     // - Is owned by the IpServer that is responsible for that downstream.
@@ -240,6 +258,16 @@
     private final HashMap<IpServer, LinkedHashMap<Inet6Address, Ipv6DownstreamRule>>
             mIpv6DownstreamRules = new LinkedHashMap<>();
 
+    // Map of IPv6 upstream rules maps. Each of these maps represents the IPv6 upstream rules for a
+    // given downstream. Each map:
+    // - Is owned by the IpServer that is responsible for that downstream.
+    // - Must only be modified by that IpServer.
+    // - Is created when the IpServer adds its first upstream rule, and deleted when the IpServer
+    //   deletes its last upstream rule (or clears its upstream rules)
+    // - Each upstream rule in the ArraySet is corresponding to an upstream interface.
+    private final ArrayMap<IpServer, ArraySet<Ipv6UpstreamRule>>
+            mIpv6UpstreamRules = new ArrayMap<>();
+
     // Map of downstream client maps. Each of these maps represents the IPv4 clients for a given
     // downstream. Needed to build IPv4 forwarding rules when conntrack events are received.
     // Each map:
@@ -603,130 +631,176 @@
     }
 
     /**
-     * Add IPv6 downstream rule. After adding the first rule on a given upstream, must add the data
-     * limit on the given upstream.
-     * Note that this can be only called on handler thread.
+     * Add IPv6 upstream rule. After adding the first rule on a given upstream, must add the
+     * data limit on the given upstream.
      */
-    public void addIpv6DownstreamRule(
-            @NonNull final IpServer ipServer, @NonNull final Ipv6DownstreamRule rule) {
+    private void addIpv6UpstreamRule(
+            @NonNull final IpServer ipServer, @NonNull final Ipv6UpstreamRule rule) {
         if (!isUsingBpf()) return;
 
-        // TODO: Perhaps avoid to add a duplicate rule.
-        if (!mBpfCoordinatorShim.addIpv6DownstreamRule(rule)) return;
-
-        if (!mIpv6DownstreamRules.containsKey(ipServer)) {
-            mIpv6DownstreamRules.put(ipServer, new LinkedHashMap<Inet6Address,
-                    Ipv6DownstreamRule>());
-        }
-        LinkedHashMap<Inet6Address, Ipv6DownstreamRule> rules = mIpv6DownstreamRules.get(ipServer);
-
         // Add upstream and downstream interface index to dev map.
         maybeAddDevMap(rule.upstreamIfindex, rule.downstreamIfindex);
 
         // When the first rule is added to an upstream, setup upstream forwarding and data limit.
         maybeSetLimit(rule.upstreamIfindex);
 
-        if (!isAnyRuleFromDownstreamToUpstream(rule.downstreamIfindex, rule.upstreamIfindex)) {
-            // TODO: support upstream forwarding on non-point-to-point interfaces.
-            // TODO: get the MTU from LinkProperties and update the rules when it changes.
-            Ipv6UpstreamRule upstreamRule = new Ipv6UpstreamRule(rule.upstreamIfindex,
-                    rule.downstreamIfindex, IPV6_ZERO_PREFIX64, rule.srcMac, NULL_MAC_ADDRESS,
-                    NULL_MAC_ADDRESS);
-            if (!mBpfCoordinatorShim.addIpv6UpstreamRule(upstreamRule)) {
-                mLog.e("Failed to add upstream IPv6 forwarding rule: " + upstreamRule);
-            }
+        // TODO: support upstream forwarding on non-point-to-point interfaces.
+        // TODO: get the MTU from LinkProperties and update the rules when it changes.
+        if (!mBpfCoordinatorShim.addIpv6UpstreamRule(rule)) {
+            return;
         }
 
-        // Must update the adding rule after calling #isAnyRuleOnUpstream because it needs to
-        // check if it is about adding a first rule for a given upstream.
+        ArraySet<Ipv6UpstreamRule> rules = mIpv6UpstreamRules.computeIfAbsent(
+                ipServer, k -> new ArraySet<Ipv6UpstreamRule>());
+        rules.add(rule);
+    }
+
+    /**
+     * Clear all IPv6 upstream rules for a given downstream. After removing the last rule on a given
+     * upstream, must clear data limit, update the last tether stats and remove the tether stats in
+     * the BPF maps.
+     */
+    private void clearIpv6UpstreamRules(@NonNull final IpServer ipServer) {
+        if (!isUsingBpf()) return;
+
+        final ArraySet<Ipv6UpstreamRule> upstreamRules = mIpv6UpstreamRules.remove(ipServer);
+        if (upstreamRules == null) return;
+
+        int upstreamIfindex = 0;
+        for (Ipv6UpstreamRule rule: upstreamRules) {
+            if (upstreamIfindex != 0 && rule.upstreamIfindex != upstreamIfindex) {
+                Log.wtf(TAG, "BUG: upstream rules point to more than one interface");
+            }
+            upstreamIfindex = rule.upstreamIfindex;
+            mBpfCoordinatorShim.removeIpv6UpstreamRule(rule);
+        }
+        // Clear the limit if there are no more rules on the given upstream.
+        // Using upstreamIfindex outside the loop is fine because all the rules for a given IpServer
+        // will always have the same upstream index (since they are always added all together by
+        // updateAllIpv6Rules).
+        // The upstreamIfindex can't be 0 because we won't add an Ipv6UpstreamRule with
+        // upstreamIfindex == 0 and if there is no Ipv6UpstreamRule for an IpServer, it will be
+        // removed from mIpv6UpstreamRules.
+        if (upstreamIfindex == 0) {
+            Log.wtf(TAG, "BUG: upstream rules have empty Set or rule.upstreamIfindex == 0");
+            return;
+        }
+        maybeClearLimit(upstreamIfindex);
+    }
+
+    /**
+     * Add IPv6 downstream rule.
+     * Note that this can be only called on handler thread.
+     */
+    public void addIpv6DownstreamRule(
+            @NonNull final IpServer ipServer, @NonNull final Ipv6DownstreamRule rule) {
+        if (!isUsingBpf()) return;
+
+        // TODO: Perhaps avoid to add a duplicate rule.
+        if (rule.upstreamIfindex != NO_UPSTREAM
+                && !mBpfCoordinatorShim.addIpv6DownstreamRule(rule)) return;
+
+        LinkedHashMap<Inet6Address, Ipv6DownstreamRule> rules =
+                mIpv6DownstreamRules.computeIfAbsent(ipServer,
+                        k -> new LinkedHashMap<Inet6Address, Ipv6DownstreamRule>());
         rules.put(rule.address, rule);
     }
 
     /**
-     * Remove IPv6 downstream rule. After removing the last rule on a given upstream, must clear
-     * data limit, update the last tether stats and remove the tether stats in the BPF maps.
+     * Remove IPv6 downstream rule.
      * Note that this can be only called on handler thread.
      */
     public void removeIpv6DownstreamRule(
             @NonNull final IpServer ipServer, @NonNull final Ipv6DownstreamRule rule) {
         if (!isUsingBpf()) return;
 
-        if (!mBpfCoordinatorShim.removeIpv6DownstreamRule(rule)) return;
+        if (rule.upstreamIfindex != NO_UPSTREAM
+                && !mBpfCoordinatorShim.removeIpv6DownstreamRule(rule)) return;
 
         LinkedHashMap<Inet6Address, Ipv6DownstreamRule> rules = mIpv6DownstreamRules.get(ipServer);
         if (rules == null) return;
 
-        // Must remove rules before calling #isAnyRuleOnUpstream because it needs to check if
-        // the last rule is removed for a given upstream. If no rule is removed, return early.
-        // Avoid unnecessary work on a non-existent rule which may have never been added or
-        // removed already.
+        // If no rule is removed, return early. Avoid unnecessary work on a non-existent rule which
+        // may have never been added or removed already.
         if (rules.remove(rule.address) == null) return;
 
         // Remove the downstream entry if it has no more rule.
         if (rules.isEmpty()) {
             mIpv6DownstreamRules.remove(ipServer);
         }
+    }
 
-        // If no more rules between this upstream and downstream, stop upstream forwarding.
-        if (!isAnyRuleFromDownstreamToUpstream(rule.downstreamIfindex, rule.upstreamIfindex)) {
-            Ipv6UpstreamRule upstreamRule = new Ipv6UpstreamRule(rule.upstreamIfindex,
-                    rule.downstreamIfindex, IPV6_ZERO_PREFIX64, rule.srcMac, NULL_MAC_ADDRESS,
-                    NULL_MAC_ADDRESS);
-            if (!mBpfCoordinatorShim.removeIpv6UpstreamRule(upstreamRule)) {
-                mLog.e("Failed to remove upstream IPv6 forwarding rule: " + upstreamRule);
-            }
+    /**
+      * Clear all downstream rules for a given IpServer and return a copy of all removed rules.
+      */
+    @Nullable
+    private Collection<Ipv6DownstreamRule> clearIpv6DownstreamRules(
+            @NonNull final IpServer ipServer) {
+        final LinkedHashMap<Inet6Address, Ipv6DownstreamRule> downstreamRules =
+                mIpv6DownstreamRules.remove(ipServer);
+        if (downstreamRules == null) return null;
+
+        final Collection<Ipv6DownstreamRule> removedRules = downstreamRules.values();
+        for (final Ipv6DownstreamRule rule : removedRules) {
+            if (rule.upstreamIfindex == NO_UPSTREAM) continue;
+            mBpfCoordinatorShim.removeIpv6DownstreamRule(rule);
         }
-
-        // Do cleanup functionality if there is no more rule on the given upstream.
-        maybeClearLimit(rule.upstreamIfindex);
+        return removedRules;
     }
 
     /**
      * Clear all forwarding rules for a given downstream.
      * Note that this can be only called on handler thread.
-     * TODO: rename to tetherOffloadRuleClear6 because of IPv6 only.
      */
-    public void tetherOffloadRuleClear(@NonNull final IpServer ipServer) {
+    public void clearAllIpv6Rules(@NonNull final IpServer ipServer) {
         if (!isUsingBpf()) return;
 
-        final LinkedHashMap<Inet6Address, Ipv6DownstreamRule> rules =
-                mIpv6DownstreamRules.get(ipServer);
-        if (rules == null) return;
-
-        // Need to build a rule list because the rule map may be changed in the iteration.
-        for (final Ipv6DownstreamRule rule : new ArrayList<Ipv6DownstreamRule>(rules.values())) {
-            removeIpv6DownstreamRule(ipServer, rule);
-        }
+        // Clear downstream rules first, because clearing upstream rules fetches the stats, and
+        // fetching the stats requires that no rules be forwarding traffic to or from the upstream.
+        clearIpv6DownstreamRules(ipServer);
+        clearIpv6UpstreamRules(ipServer);
     }
 
     /**
-     * Update existing forwarding rules to new upstream for a given downstream.
+     * Delete all upstream and downstream rules for the passed-in IpServer, and if the new upstream
+     * is nonzero, reapply them to the new upstream.
      * Note that this can be only called on handler thread.
      */
-    public void tetherOffloadRuleUpdate(@NonNull final IpServer ipServer, int newUpstreamIfindex) {
+    public void updateAllIpv6Rules(@NonNull final IpServer ipServer,
+            final InterfaceParams interfaceParams, int newUpstreamIfindex,
+            @NonNull final Set<IpPrefix> newUpstreamPrefixes) {
         if (!isUsingBpf()) return;
 
-        final LinkedHashMap<Inet6Address, Ipv6DownstreamRule> rules =
-                mIpv6DownstreamRules.get(ipServer);
-        if (rules == null) return;
+        // Remove IPv6 downstream rules. Remove the old ones before adding the new rules, otherwise
+        // we need to keep a copy of the old rules.
+        // We still need to keep the downstream rules even when the upstream goes away because it
+        // may come back with the same prefixes (unlikely, but possible). Neighbor entries won't be
+        // deleted and we're not expected to receive new Neighbor events in this case.
+        // TODO: Add new rule first to reduce the latency which has no rule. But this is okay
+        //       because if this is a new upstream, it will probably have different prefixes than
+        //       the one these downstream rules are in. If so, they will never see any downstream
+        //       traffic before new neighbor entries are created.
+        final Collection<Ipv6DownstreamRule> deletedDownstreamRules =
+                clearIpv6DownstreamRules(ipServer);
 
-        // Need to build a rule list because the rule map may be changed in the iteration.
-        // First remove all the old rules, then add all the new rules. This is because the upstream
-        // forwarding code in addIpv6DownstreamRule cannot support rules on two upstreams at the
-        // same time. Deleting the rules first ensures that upstream forwarding is disabled on the
-        // old upstream when the last rule is removed from it, and re-enabled on the new upstream
-        // when the first rule is added to it.
-        // TODO: Once the IPv6 client processing code has moved from IpServer to BpfCoordinator, do
-        // something smarter.
-        final ArrayList<Ipv6DownstreamRule> rulesCopy = new ArrayList<>(rules.values());
-        for (final Ipv6DownstreamRule rule : rulesCopy) {
-            // Remove the old rule before adding the new one because the map uses the same key for
-            // both rules. Reversing the processing order causes that the new rule is removed as
-            // unexpected.
-            // TODO: Add new rule first to reduce the latency which has no rule.
-            removeIpv6DownstreamRule(ipServer, rule);
+        // Remove IPv6 upstream rules. Downstream rules must be removed first because
+        // BpfCoordinatorShimImpl#tetherOffloadGetAndClearStats will be called after the removal of
+        // the last upstream rule and it requires that no rules be forwarding traffic to or from
+        // that upstream.
+        clearIpv6UpstreamRules(ipServer);
+
+        // Add new upstream rules.
+        if (newUpstreamIfindex != 0 && interfaceParams != null && interfaceParams.macAddr != null) {
+            for (final IpPrefix ipPrefix : newUpstreamPrefixes) {
+                addIpv6UpstreamRule(ipServer, new Ipv6UpstreamRule(
+                        newUpstreamIfindex, interfaceParams.index, ipPrefix,
+                        interfaceParams.macAddr, NULL_MAC_ADDRESS, NULL_MAC_ADDRESS));
+            }
         }
-        for (final Ipv6DownstreamRule rule : rulesCopy) {
+
+        // Add updated downstream rules.
+        if (deletedDownstreamRules == null) return;
+        for (final Ipv6DownstreamRule rule : deletedDownstreamRules) {
             addIpv6DownstreamRule(ipServer, rule.onNewUpstream(newUpstreamIfindex));
         }
     }
@@ -737,7 +811,7 @@
      * expects the interface name in NetworkStats object.
      * Note that this can be only called on handler thread.
      */
-    public void addUpstreamNameToLookupTable(int upstreamIfindex, @NonNull String upstreamIface) {
+    public void maybeAddUpstreamToLookupTable(int upstreamIfindex, @Nullable String upstreamIface) {
         if (!isUsingBpf()) return;
 
         if (upstreamIfindex == 0 || TextUtils.isEmpty(upstreamIface)) return;
@@ -1007,7 +1081,7 @@
      * TODO: consider error handling if the attach program failed.
      */
     public void maybeAttachProgram(@NonNull String intIface, @NonNull String extIface) {
-        if (isVcnInterface(extIface)) return;
+        if (!isUsingBpf() || isVcnInterface(extIface)) return;
 
         if (forwardingPairExists(intIface, extIface)) return;
 
@@ -1031,6 +1105,8 @@
      * Detach BPF program
      */
     public void maybeDetachProgram(@NonNull String intIface, @NonNull String extIface) {
+        if (!isUsingBpf()) return;
+
         forwardingPairRemove(intIface, extIface);
 
         // Detaching program may fail because the interface has been removed already.
@@ -1182,10 +1258,24 @@
         pw.decreaseIndent();
     }
 
+    private IpPrefix longToPrefix(long ip64) {
+        final ByteBuffer prefixBuffer = ByteBuffer.allocate(IPV6_ADDR_LEN);
+        prefixBuffer.putLong(ip64);
+        IpPrefix sourcePrefix;
+        try {
+            sourcePrefix = new IpPrefix(InetAddress.getByAddress(prefixBuffer.array()), 64);
+        } catch (UnknownHostException e) {
+            // Cannot happen. InetAddress.getByAddress can only throw an exception if the byte array
+            // is the wrong length, but we allocate it with fixed length IPV6_ADDR_LEN.
+            throw new IllegalArgumentException("Invalid IPv6 address");
+        }
+        return sourcePrefix;
+    }
+
     private String ipv6UpstreamRuleToString(TetherUpstream6Key key, Tether6Value value) {
-        return String.format("%d(%s) [%s] -> %d(%s) %04x [%s] [%s]",
-                key.iif, getIfName(key.iif), key.dstMac, value.oif, getIfName(value.oif),
-                value.ethProto, value.ethSrcMac, value.ethDstMac);
+        return String.format("%d(%s) [%s] [%s] -> %d(%s) %04x [%s] [%s]",
+                key.iif, getIfName(key.iif), key.dstMac, longToPrefix(key.src64), value.oif,
+                getIfName(value.oif), value.ethProto, value.ethSrcMac, value.ethDstMac);
     }
 
     private void dumpIpv6UpstreamRules(IndentingPrintWriter pw) {
@@ -1235,8 +1325,8 @@
     // TODO: use dump utils with headerline and lambda which prints key and value to reduce
     // duplicate bpf map dump code.
     private void dumpBpfForwardingRulesIpv6(IndentingPrintWriter pw) {
-        pw.println("IPv6 Upstream: iif(iface) [inDstMac] -> oif(iface) etherType [outSrcMac] "
-                + "[outDstMac]");
+        pw.println("IPv6 Upstream: iif(iface) [inDstMac] [sourcePrefix] -> oif(iface) etherType "
+                + "[outSrcMac] [outDstMac]");
         pw.increaseIndent();
         dumpIpv6UpstreamRules(pw);
         pw.decreaseIndent();
@@ -1480,8 +1570,7 @@
          */
         @NonNull
         public TetherUpstream6Key makeTetherUpstream6Key() {
-            byte[] prefixBytes = Arrays.copyOf(sourcePrefix.getRawAddress(), 8);
-            long prefix64 = ByteBuffer.wrap(prefixBytes).order(ByteOrder.BIG_ENDIAN).getLong();
+            long prefix64 = ByteBuffer.wrap(sourcePrefix.getRawAddress()).getLong();
             return new TetherUpstream6Key(downstreamIfindex, inDstMac, prefix64);
         }
 
@@ -1967,9 +2056,8 @@
     }
 
     private int getInterfaceIndexFromRules(@NonNull String ifName) {
-        for (LinkedHashMap<Inet6Address, Ipv6DownstreamRule> rules :
-                mIpv6DownstreamRules.values()) {
-            for (Ipv6DownstreamRule rule : rules.values()) {
+        for (ArraySet<Ipv6UpstreamRule> rules : mIpv6UpstreamRules.values()) {
+            for (Ipv6UpstreamRule rule : rules) {
                 final int upstreamIfindex = rule.upstreamIfindex;
                 if (TextUtils.equals(ifName, mInterfaceNames.get(upstreamIfindex))) {
                     return upstreamIfindex;
@@ -1987,6 +2075,7 @@
     }
 
     private boolean sendDataLimitToBpfMap(int ifIndex, long quotaBytes) {
+        if (!isUsingBpf()) return false;
         if (ifIndex == 0) {
             Log.wtf(TAG, "Invalid interface index.");
             return false;
@@ -2060,28 +2149,14 @@
     // TODO: Rename to isAnyIpv6RuleOnUpstream and define an isAnyRuleOnUpstream method that called
     // both isAnyIpv6RuleOnUpstream and mBpfCoordinatorShim.isAnyIpv4RuleOnUpstream.
     private boolean isAnyRuleOnUpstream(int upstreamIfindex) {
-        for (LinkedHashMap<Inet6Address, Ipv6DownstreamRule> rules :
-                mIpv6DownstreamRules.values()) {
-            for (Ipv6DownstreamRule rule : rules.values()) {
+        for (ArraySet<Ipv6UpstreamRule> rules : mIpv6UpstreamRules.values()) {
+            for (Ipv6UpstreamRule rule : rules) {
                 if (upstreamIfindex == rule.upstreamIfindex) return true;
             }
         }
         return false;
     }
 
-    private boolean isAnyRuleFromDownstreamToUpstream(int downstreamIfindex, int upstreamIfindex) {
-        for (LinkedHashMap<Inet6Address, Ipv6DownstreamRule> rules :
-                mIpv6DownstreamRules.values()) {
-            for (Ipv6DownstreamRule rule : rules.values()) {
-                if (downstreamIfindex == rule.downstreamIfindex
-                        && upstreamIfindex == rule.upstreamIfindex) {
-                    return true;
-                }
-            }
-        }
-        return false;
-    }
-
     // TODO: remove the index from map while the interface has been removed because the map size
     // is 64 entries. See packages\modules\Connectivity\Tethering\bpf_progs\offload.c.
     private void maybeAddDevMap(int upstreamIfindex, int downstreamIfindex) {
diff --git a/Tethering/src/com/android/networkstack/tethering/Tethering.java b/Tethering/src/com/android/networkstack/tethering/Tethering.java
index b371178..996ee11 100644
--- a/Tethering/src/com/android/networkstack/tethering/Tethering.java
+++ b/Tethering/src/com/android/networkstack/tethering/Tethering.java
@@ -90,6 +90,7 @@
 import android.net.LinkProperties;
 import android.net.Network;
 import android.net.NetworkInfo;
+import android.net.RoutingCoordinatorManager;
 import android.net.TetherStatesParcel;
 import android.net.TetheredClient;
 import android.net.TetheringCallbackStartedParcel;
@@ -136,6 +137,7 @@
 import com.android.net.module.util.BaseNetdUnsolicitedEventListener;
 import com.android.net.module.util.CollectionUtils;
 import com.android.net.module.util.NetdUtils;
+import com.android.net.module.util.SdkUtil.LateSdk;
 import com.android.net.module.util.SharedLog;
 import com.android.networkstack.apishim.common.BluetoothPanShim;
 import com.android.networkstack.apishim.common.BluetoothPanShim.TetheredInterfaceCallbackShim;
@@ -250,6 +252,10 @@
     private final Handler mHandler;
     private final INetd mNetd;
     private final NetdCallback mNetdCallback;
+    // Contains null if the connectivity module is unsupported, as the routing coordinator is not
+    // available. Must use LateSdk because MessageUtils enumerates fields in this class, so it
+    // must be able to find all classes at runtime.
+    @NonNull private final LateSdk<RoutingCoordinatorManager> mRoutingCoordinator;
     private final UserRestrictionActionListener mTetheringRestriction;
     private final ActiveDataSubIdListener mActiveDataSubIdListener;
     private final ConnectedClientsTracker mConnectedClientsTracker;
@@ -296,6 +302,7 @@
         mDeps = deps;
         mContext = mDeps.getContext();
         mNetd = mDeps.getINetd(mContext);
+        mRoutingCoordinator = mDeps.getRoutingCoordinator(mContext);
         mLooper = mDeps.getTetheringLooper();
         mNotificationUpdater = mDeps.getNotificationUpdater(mContext, mLooper);
         mTetheringMetrics = mDeps.getTetheringMetrics();
@@ -1680,6 +1687,8 @@
         static final int EVENT_IFACE_UPDATE_LINKPROPERTIES      = BASE_MAIN_SM + 7;
         // Events from EntitlementManager to choose upstream again.
         static final int EVENT_UPSTREAM_PERMISSION_CHANGED      = BASE_MAIN_SM + 8;
+        // Internal request from IpServer to enable or disable downstream.
+        static final int EVENT_REQUEST_CHANGE_DOWNSTREAM        = BASE_MAIN_SM + 9;
         private final State mInitialState;
         private final State mTetherModeAliveState;
 
@@ -2179,6 +2188,12 @@
                         }
                         break;
                     }
+                    case EVENT_REQUEST_CHANGE_DOWNSTREAM: {
+                        final int tetheringType = message.arg1;
+                        final Boolean enabled = (Boolean) message.obj;
+                        enableTetheringInternal(tetheringType, enabled, null);
+                        break;
+                    }
                     default:
                         retValue = false;
                         break;
@@ -2736,7 +2751,8 @@
 
             @Override
             public void requestEnableTethering(int tetheringType, boolean enabled) {
-                enableTetheringInternal(tetheringType, enabled, null);
+                mTetherMainSM.sendMessage(TetherMainSM.EVENT_REQUEST_CHANGE_DOWNSTREAM,
+                        tetheringType, 0, enabled ? Boolean.TRUE : Boolean.FALSE);
             }
         };
     }
@@ -2834,9 +2850,10 @@
 
         mLog.i("adding IpServer for: " + iface);
         final TetherState tetherState = new TetherState(
-                new IpServer(iface, mLooper, interfaceType, mLog, mNetd, mBpfCoordinator,
-                             makeControlCallback(), mConfig, mPrivateAddressCoordinator,
-                             mTetheringMetrics, mDeps.getIpServerDependencies()), isNcm);
+                new IpServer(iface, mHandler, interfaceType, mLog, mNetd, mBpfCoordinator,
+                        mRoutingCoordinator, makeControlCallback(), mConfig,
+                        mPrivateAddressCoordinator, mTetheringMetrics,
+                        mDeps.getIpServerDependencies()), isNcm);
         mTetherStates.put(iface, tetherState);
         tetherState.ipServer.start();
     }
diff --git a/Tethering/src/com/android/networkstack/tethering/TetheringConfiguration.java b/Tethering/src/com/android/networkstack/tethering/TetheringConfiguration.java
index 747cc20..502fee8 100644
--- a/Tethering/src/com/android/networkstack/tethering/TetheringConfiguration.java
+++ b/Tethering/src/com/android/networkstack/tethering/TetheringConfiguration.java
@@ -136,6 +136,9 @@
      */
     public static final int DEFAULT_TETHER_OFFLOAD_POLL_INTERVAL_MS = 5000;
 
+    /** A flag for using synchronous or asynchronous state machine. */
+    public static final boolean USE_SYNC_SM = false;
+
     public final String[] tetherableUsbRegexs;
     public final String[] tetherableWifiRegexs;
     public final String[] tetherableWigigRegexs;
diff --git a/Tethering/src/com/android/networkstack/tethering/TetheringDependencies.java b/Tethering/src/com/android/networkstack/tethering/TetheringDependencies.java
index 741a5c5..c6468a0 100644
--- a/Tethering/src/com/android/networkstack/tethering/TetheringDependencies.java
+++ b/Tethering/src/com/android/networkstack/tethering/TetheringDependencies.java
@@ -16,11 +16,14 @@
 
 package com.android.networkstack.tethering;
 
+import android.annotation.Nullable;
 import android.app.usage.NetworkStatsManager;
 import android.bluetooth.BluetoothAdapter;
 import android.bluetooth.BluetoothPan;
 import android.content.Context;
 import android.net.INetd;
+import android.net.RoutingCoordinatorManager;
+import android.net.connectivity.TiramisuConnectivityInternalApiUtil;
 import android.net.ip.IpServer;
 import android.os.Build;
 import android.os.Handler;
@@ -33,6 +36,8 @@
 import androidx.annotation.RequiresApi;
 
 import com.android.internal.util.StateMachine;
+import com.android.modules.utils.build.SdkLevel;
+import com.android.net.module.util.SdkUtil.LateSdk;
 import com.android.net.module.util.SharedLog;
 import com.android.networkstack.apishim.BluetoothPanShimImpl;
 import com.android.networkstack.apishim.common.BluetoothPanShim;
@@ -122,6 +127,16 @@
     }
 
     /**
+     * Get the routing coordinator, or null if below S.
+     */
+    @Nullable
+    public LateSdk<RoutingCoordinatorManager> getRoutingCoordinator(Context context) {
+        if (!SdkLevel.isAtLeastS()) return new LateSdk<>(null);
+        return new LateSdk<>(
+                TiramisuConnectivityInternalApiUtil.getRoutingCoordinatorManager(context));
+    }
+
+    /**
      * Get a reference to the TetheringNotificationUpdater to be used by tethering.
      */
     public TetheringNotificationUpdater getNotificationUpdater(@NonNull final Context ctx,
@@ -135,7 +150,7 @@
     public abstract Looper getTetheringLooper();
 
     /**
-     *  Get Context of TetheringSerice.
+     *  Get Context of TetheringService.
      */
     public abstract Context getContext();
 
diff --git a/Tethering/src/com/android/networkstack/tethering/util/StateMachineShim.java b/Tethering/src/com/android/networkstack/tethering/util/StateMachineShim.java
new file mode 100644
index 0000000..078a35f
--- /dev/null
+++ b/Tethering/src/com/android/networkstack/tethering/util/StateMachineShim.java
@@ -0,0 +1,196 @@
+/*
+ * 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.networkstack.tethering.util;
+
+import android.annotation.Nullable;
+import android.os.Looper;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.internal.util.State;
+import com.android.internal.util.StateMachine;
+import com.android.networkstack.tethering.util.SyncStateMachine.StateInfo;
+
+import java.util.List;
+
+/** A wrapper to decide whether use synchronous state machine for tethering. */
+public class StateMachineShim {
+    // Exactly one of mAsyncSM or mSyncSM is non-null.
+    private final AsyncStateMachine mAsyncSM;
+    private final SyncStateMachine mSyncSM;
+
+    /**
+     * The Looper parameter is only needed for AsyncSM, so if looper is null, the shim will be
+     * created for SyncSM.
+     */
+    public StateMachineShim(final String name, @Nullable final Looper looper) {
+        this(name, looper, new Dependencies());
+    }
+
+    @VisibleForTesting
+    public StateMachineShim(final String name, @Nullable final Looper looper,
+            final Dependencies deps) {
+        if (looper == null) {
+            mAsyncSM = null;
+            mSyncSM = deps.makeSyncStateMachine(name, Thread.currentThread());
+        } else {
+            mAsyncSM = deps.makeAsyncStateMachine(name, looper);
+            mSyncSM = null;
+        }
+    }
+
+    /** A dependencies class which used for testing injection. */
+    @VisibleForTesting
+    public static class Dependencies {
+        /** Create SyncSM instance, for injection. */
+        public SyncStateMachine makeSyncStateMachine(final String name, final Thread thread) {
+            return new SyncStateMachine(name, thread);
+        }
+
+        /** Create AsyncSM instance, for injection. */
+        public AsyncStateMachine makeAsyncStateMachine(final String name, final Looper looper) {
+            return new AsyncStateMachine(name, looper);
+        }
+    }
+
+    /** Start the state machine */
+    public void start(final State initialState) {
+        if (mSyncSM != null) {
+            mSyncSM.start(initialState);
+        } else {
+            mAsyncSM.setInitialState(initialState);
+            mAsyncSM.start();
+        }
+    }
+
+    /** Add states to state machine. */
+    public void addAllStates(final List<StateInfo> stateInfos) {
+        if (mSyncSM != null) {
+            mSyncSM.addAllStates(stateInfos);
+        } else {
+            for (final StateInfo info : stateInfos) {
+                mAsyncSM.addState(info.state, info.parent);
+            }
+        }
+    }
+
+    /**
+     * Transition to given state.
+     *
+     * SyncSM doesn't allow this be called during state transition (#enter() or #exit() methods),
+     * or multiple times while processing a single message.
+     */
+    public void transitionTo(final State state) {
+        if (mSyncSM != null) {
+            mSyncSM.transitionTo(state);
+        } else {
+            mAsyncSM.transitionTo(state);
+        }
+    }
+
+    /** Send message to state machine. */
+    public void sendMessage(int what) {
+        sendMessage(what, 0, 0, null);
+    }
+
+    /** Send message to state machine. */
+    public void sendMessage(int what, Object obj) {
+        sendMessage(what, 0, 0, obj);
+    }
+
+    /** Send message to state machine. */
+    public void sendMessage(int what, int arg1) {
+        sendMessage(what, arg1, 0, null);
+    }
+
+    /**
+     * Send message to state machine.
+     *
+     * If using asynchronous state machine, putting the message into looper's message queue.
+     * Tethering runs on single looper thread that ipServers and mainSM all share with same message
+     * queue. The enqueued message will be processed by asynchronous state machine when all the
+     * messages before such enqueued message are processed.
+     * If using synchronous state machine, the message is processed right away without putting into
+     * looper's message queue.
+     */
+    public void sendMessage(int what, int arg1, int arg2, Object obj) {
+        if (mSyncSM != null) {
+            mSyncSM.processMessage(what, arg1, arg2, obj);
+        } else {
+            mAsyncSM.sendMessage(what, arg1, arg2, obj);
+        }
+    }
+
+    /**
+     * Send message after delayMillis millisecond.
+     *
+     * This can only be used with async state machine, so this will throw if using sync state
+     * machine.
+     */
+    public void sendMessageDelayedToAsyncSM(final int what, final long delayMillis) {
+        if (mSyncSM != null) {
+            throw new IllegalStateException("sendMessageDelayed can only be used with async SM");
+        }
+
+        mAsyncSM.sendMessageDelayed(what, delayMillis);
+    }
+
+    /**
+     * Enqueue a message to the front of the queue.
+     * Protected, may only be called by instances of async state machine.
+     *
+     * Message is ignored if state machine has quit.
+     */
+    protected void sendMessageAtFrontOfQueueToAsyncSM(int what, int arg1) {
+        if (mSyncSM != null) {
+            throw new IllegalStateException("sendMessageAtFrontOfQueue can only be used with"
+                    + " async SM");
+        }
+
+        mAsyncSM.sendMessageAtFrontOfQueueToAsyncSM(what, arg1);
+    }
+
+    /**
+     * Send self message.
+     * This can only be used with sync state machine, so this will throw if using async state
+     * machine.
+     */
+    public void sendSelfMessageToSyncSM(final int what, final Object obj) {
+        if (mSyncSM == null) {
+            throw new IllegalStateException("sendSelfMessage can only be used with sync SM");
+        }
+
+        mSyncSM.sendSelfMessage(what, 0, 0, obj);
+    }
+
+    /**
+     * An alias StateMahchine class with public construtor.
+     *
+     * Since StateMachine.java only provides protected construtor, adding a child class so that this
+     * shim could create StateMachine instance.
+     */
+    @VisibleForTesting
+    public static class AsyncStateMachine extends StateMachine {
+        public AsyncStateMachine(final String name, final Looper looper) {
+            super(name, looper);
+        }
+
+        /** Enqueue a message to the front of the queue for this state machine. */
+        public void sendMessageAtFrontOfQueueToAsyncSM(int what, int arg1) {
+            sendMessageAtFrontOfQueue(what, arg1);
+        }
+    }
+}
diff --git a/Tethering/tests/privileged/src/android/net/ip/RouterAdvertisementDaemonTest.java b/Tethering/tests/privileged/src/android/net/ip/RouterAdvertisementDaemonTest.java
index 328e3fb..dac5b63 100644
--- a/Tethering/tests/privileged/src/android/net/ip/RouterAdvertisementDaemonTest.java
+++ b/Tethering/tests/privileged/src/android/net/ip/RouterAdvertisementDaemonTest.java
@@ -16,8 +16,6 @@
 
 package android.net.ip;
 
-import static android.net.RouteInfo.RTN_UNICAST;
-
 import static com.android.net.module.util.NetworkStackConstants.ETHER_HEADER_LEN;
 import static com.android.net.module.util.NetworkStackConstants.ETHER_TYPE_IPV6;
 import static com.android.net.module.util.NetworkStackConstants.ICMPV6_ND_OPTION_MTU;
@@ -42,12 +40,13 @@
 import android.net.INetd;
 import android.net.IpPrefix;
 import android.net.MacAddress;
-import android.net.RouteInfo;
 import android.net.ip.RouterAdvertisementDaemon.RaParams;
 import android.os.Handler;
 import android.os.HandlerThread;
 import android.os.IBinder;
 import android.os.Looper;
+import android.os.RemoteException;
+import android.os.ServiceSpecificException;
 
 import androidx.test.InstrumentationRegistry;
 import androidx.test.filters.SmallTest;
@@ -55,7 +54,6 @@
 
 import com.android.net.module.util.InterfaceParams;
 import com.android.net.module.util.Ipv6Utils;
-import com.android.net.module.util.NetdUtils;
 import com.android.net.module.util.Struct;
 import com.android.net.module.util.structs.EthernetHeader;
 import com.android.net.module.util.structs.Icmpv6Header;
@@ -80,7 +78,6 @@
 import java.net.InetAddress;
 import java.nio.ByteBuffer;
 import java.util.HashSet;
-import java.util.List;
 
 @RunWith(AndroidJUnit4.class)
 @SmallTest
@@ -332,10 +329,12 @@
         // Add a default route "fe80::/64 -> ::" to local network, otherwise, device will fail to
         // send the unicast RA out due to the ENETUNREACH error(No route to the peer's link-local
         // address is present).
-        final String iface = mTetheredParams.name;
-        final RouteInfo linkLocalRoute =
-                new RouteInfo(new IpPrefix("fe80::/64"), null, iface, RTN_UNICAST);
-        NetdUtils.addRoutesToLocalNetwork(sNetd, iface, List.of(linkLocalRoute));
+        try {
+            sNetd.networkAddRoute(INetd.LOCAL_NET_ID, mTetheredParams.name,
+                    "fe80::/64", INetd.NEXTHOP_NONE);
+        } catch (RemoteException | ServiceSpecificException e) {
+            throw new IllegalStateException(e);
+        }
 
         final ByteBuffer rs = createRsPacket("fe80::1122:3344:5566:7788");
         mTetheredPacketReader.sendResponse(rs);
diff --git a/Tethering/tests/privileged/src/com/android/networkstack/tethering/ConntrackSocketTest.java b/Tethering/tests/privileged/src/com/android/networkstack/tethering/ConntrackSocketTest.java
index 81d4fbe..60f2d17 100644
--- a/Tethering/tests/privileged/src/com/android/networkstack/tethering/ConntrackSocketTest.java
+++ b/Tethering/tests/privileged/src/com/android/networkstack/tethering/ConntrackSocketTest.java
@@ -44,6 +44,7 @@
 import com.android.net.module.util.netlink.NetlinkUtils;
 import com.android.net.module.util.netlink.StructNlMsgHdr;
 
+import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -84,6 +85,14 @@
         mOffloadHw = new OffloadHardwareInterface(mHandler, mLog, mDeps);
     }
 
+    @After
+    public void tearDown() throws Exception {
+        if (mHandlerThread != null) {
+            mHandlerThread.quitSafely();
+            mHandlerThread.join();
+        }
+    }
+
     void findConnectionOrThrow(FileDescriptor fd, InetSocketAddress local, InetSocketAddress remote)
             throws Exception {
         Log.d(TAG, "Looking for socket " + local + " -> " + remote);
diff --git a/Tethering/tests/unit/src/android/net/ip/IpServerTest.java b/Tethering/tests/unit/src/android/net/ip/IpServerTest.java
index c0718d1..98b624b 100644
--- a/Tethering/tests/unit/src/android/net/ip/IpServerTest.java
+++ b/Tethering/tests/unit/src/android/net/ip/IpServerTest.java
@@ -34,6 +34,7 @@
 import static android.net.ip.IpServer.STATE_LOCAL_ONLY;
 import static android.net.ip.IpServer.STATE_TETHERED;
 import static android.net.ip.IpServer.STATE_UNAVAILABLE;
+import static android.net.ip.IpServer.getTetherableIpv6Prefixes;
 import static android.system.OsConstants.ETH_P_IPV6;
 
 import static com.android.modules.utils.build.SdkLevel.isAtLeastT;
@@ -62,6 +63,7 @@
 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;
@@ -80,6 +82,7 @@
 import android.net.LinkProperties;
 import android.net.MacAddress;
 import android.net.RouteInfo;
+import android.net.RoutingCoordinatorManager;
 import android.net.TetherOffloadRuleParcel;
 import android.net.TetherStatsParcel;
 import android.net.dhcp.DhcpServerCallbacks;
@@ -93,15 +96,19 @@
 import android.os.RemoteException;
 import android.os.test.TestLooper;
 import android.text.TextUtils;
+import android.util.ArrayMap;
+import android.util.ArraySet;
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.test.filters.SmallTest;
 import androidx.test.runner.AndroidJUnit4;
 
+import com.android.modules.utils.build.SdkLevel;
 import com.android.net.module.util.BpfMap;
 import com.android.net.module.util.InterfaceParams;
 import com.android.net.module.util.NetworkStackConstants;
+import com.android.net.module.util.SdkUtil.LateSdk;
 import com.android.net.module.util.SharedLog;
 import com.android.net.module.util.Struct.S32;
 import com.android.net.module.util.bpf.Tether4Key;
@@ -141,12 +148,15 @@
 import org.mockito.InOrder;
 import org.mockito.Mock;
 import org.mockito.MockitoAnnotations;
+import org.mockito.verification.VerificationMode;
 
 import java.net.Inet4Address;
 import java.net.Inet6Address;
 import java.net.InetAddress;
+import java.nio.ByteBuffer;
 import java.util.Arrays;
 import java.util.List;
+import java.util.Set;
 
 @RunWith(AndroidJUnit4.class)
 @SmallTest
@@ -158,6 +168,7 @@
     private static final String UPSTREAM_IFACE = "upstream0";
     private static final String UPSTREAM_IFACE2 = "upstream1";
     private static final String IPSEC_IFACE = "ipsec0";
+    private static final int NO_UPSTREAM = 0;
     private static final int UPSTREAM_IFINDEX = 101;
     private static final int UPSTREAM_IFINDEX2 = 102;
     private static final int IPSEC_IFINDEX = 103;
@@ -183,6 +194,18 @@
     private final LinkAddress mTestAddress = new LinkAddress("192.168.42.5/24");
     private final IpPrefix mBluetoothPrefix = new IpPrefix("192.168.44.0/24");
 
+    private static final Set<LinkAddress> NO_ADDRESSES = Set.of();
+    private static final Set<IpPrefix> NO_PREFIXES = Set.of();
+    private static final Set<LinkAddress> UPSTREAM_ADDRESSES =
+            Set.of(new LinkAddress("2001:db8:0:1234::168/64"));
+    private static final Set<IpPrefix> UPSTREAM_PREFIXES =
+            Set.of(new IpPrefix("2001:db8:0:1234::/64"));
+    private static final Set<LinkAddress> UPSTREAM_ADDRESSES2 = Set.of(
+            new LinkAddress("2001:db8:0:1234::168/64"),
+            new LinkAddress("2001:db8:0:abcd::168/64"));
+    private static final Set<IpPrefix> UPSTREAM_PREFIXES2 = Set.of(
+            new IpPrefix("2001:db8:0:1234::/64"), new IpPrefix("2001:db8:0:abcd::/64"));
+
     @Mock private INetd mNetd;
     @Mock private IpServer.Callback mCallback;
     @Mock private SharedLog mSharedLog;
@@ -192,6 +215,8 @@
     @Mock private IpNeighborMonitor mIpNeighborMonitor;
     @Mock private IpServer.Dependencies mDependencies;
     @Mock private PrivateAddressCoordinator mAddressCoordinator;
+    private final LateSdk<RoutingCoordinatorManager> mRoutingCoordinatorManager =
+            new LateSdk<>(SdkLevel.isAtLeastS() ? mock(RoutingCoordinatorManager.class) : null);
     @Mock private NetworkStatsManager mStatsManager;
     @Mock private TetheringConfiguration mTetherConfig;
     @Mock private ConntrackMonitor mConntrackMonitor;
@@ -207,7 +232,8 @@
 
     @Captor private ArgumentCaptor<DhcpServingParamsParcel> mDhcpParamsCaptor;
 
-    private final TestLooper mLooper = new TestLooper();
+    private TestLooper mLooper;
+    private Handler mHandler;
     private final ArgumentCaptor<LinkProperties> mLinkPropertiesCaptor =
             ArgumentCaptor.forClass(LinkProperties.class);
     private IpServer mIpServer;
@@ -244,11 +270,7 @@
         when(mTetherConfig.isBpfOffloadEnabled()).thenReturn(usingBpfOffload);
         when(mTetherConfig.useLegacyDhcpServer()).thenReturn(usingLegacyDhcp);
         when(mTetherConfig.getP2pLeasesSubnetPrefixLength()).thenReturn(P2P_SUBNET_PREFIX_LENGTH);
-        // Recreate mBpfCoordinator again here because mTetherConfig has changed
-        mBpfCoordinator = spy(new BpfCoordinator(mBpfDeps));
-        mIpServer = new IpServer(
-                IFACE_NAME, mLooper.getLooper(), interfaceType, mSharedLog, mNetd, mBpfCoordinator,
-                mCallback, mTetherConfig, mAddressCoordinator, mTetheringMetrics, mDependencies);
+        mIpServer = createIpServer(interfaceType);
         mIpServer.start();
         mNeighborEventConsumer = neighborCaptor.getValue();
 
@@ -262,20 +284,33 @@
 
     private void initTetheredStateMachine(int interfaceType, String upstreamIface)
             throws Exception {
-        initTetheredStateMachine(interfaceType, upstreamIface, false,
+        initTetheredStateMachine(interfaceType, upstreamIface, NO_ADDRESSES, false,
                 DEFAULT_USING_BPF_OFFLOAD);
     }
 
     private void initTetheredStateMachine(int interfaceType, String upstreamIface,
-            boolean usingLegacyDhcp, boolean usingBpfOffload) throws Exception {
+            Set<LinkAddress> upstreamAddresses, boolean usingLegacyDhcp, boolean usingBpfOffload)
+            throws Exception {
         initStateMachine(interfaceType, usingLegacyDhcp, usingBpfOffload);
         dispatchCommand(IpServer.CMD_TETHER_REQUESTED, STATE_TETHERED);
         if (upstreamIface != null) {
             LinkProperties lp = new LinkProperties();
             lp.setInterfaceName(upstreamIface);
+            lp.setLinkAddresses(upstreamAddresses);
             dispatchTetherConnectionChanged(upstreamIface, lp, 0);
+            if (usingBpfOffload && !lp.getLinkAddresses().isEmpty()) {
+                Set<IpPrefix> upstreamPrefixes = getTetherableIpv6Prefixes(lp.getLinkAddresses());
+                InterfaceParams interfaceParams = mDependencies.getInterfaceParams(upstreamIface);
+                assertNotNull("missing upstream interface: " + upstreamIface, interfaceParams);
+                verify(mBpfCoordinator).updateAllIpv6Rules(
+                        mIpServer, TEST_IFACE_PARAMS, interfaceParams.index, upstreamPrefixes);
+                verifyStartUpstreamIpv6Forwarding(null, interfaceParams.index, upstreamPrefixes);
+            } else {
+                verifyNoUpstreamIpv6ForwardingChange(null);
+            }
         }
-        reset(mNetd, mCallback, mAddressCoordinator, mBpfCoordinator);
+        reset(mCallback, mAddressCoordinator);
+        resetNetdBpfMapAndCoordinator();
         when(mAddressCoordinator.requestDownstreamAddress(any(), anyInt(),
                 anyBoolean())).thenReturn(mTestAddress);
     }
@@ -303,10 +338,35 @@
         when(mTetherConfig.isBpfOffloadEnabled()).thenReturn(DEFAULT_USING_BPF_OFFLOAD);
         when(mTetherConfig.useLegacyDhcpServer()).thenReturn(false /* default value */);
 
+        // Simulate the behavior of RoutingCoordinator
+        if (null != mRoutingCoordinatorManager.value) {
+            doAnswer(it -> {
+                final String fromIface = (String) it.getArguments()[0];
+                final String toIface = (String) it.getArguments()[1];
+                mNetd.tetherAddForward(fromIface, toIface);
+                mNetd.ipfwdAddInterfaceForward(fromIface, toIface);
+                return null;
+            }).when(mRoutingCoordinatorManager.value).addInterfaceForward(any(), any());
+            doAnswer(it -> {
+                final String fromIface = (String) it.getArguments()[0];
+                final String toIface = (String) it.getArguments()[1];
+                mNetd.ipfwdRemoveInterfaceForward(fromIface, toIface);
+                mNetd.tetherRemoveForward(fromIface, toIface);
+                return null;
+            }).when(mRoutingCoordinatorManager.value).removeInterfaceForward(any(), any());
+        }
+
+        setUpDhcpServer();
+    }
+
+    // In order to interact with syncSM from the test, IpServer must be created in test thread.
+    private IpServer createIpServer(final int interfaceType) {
+        mLooper = new TestLooper();
+        mHandler = new Handler(mLooper.getLooper());
         mBpfDeps = new BpfCoordinator.Dependencies() {
                     @NonNull
                     public Handler getHandler() {
-                        return new Handler(mLooper.getLooper());
+                        return mHandler;
                     }
 
                     @NonNull
@@ -375,18 +435,19 @@
                         return mBpfErrorMap;
                     }
                 };
-        mBpfCoordinator = spy(new BpfCoordinator(mBpfDeps));
 
-        setUpDhcpServer();
+        mBpfCoordinator = spy(new BpfCoordinator(mBpfDeps));
+        return new IpServer(IFACE_NAME, mHandler, interfaceType, mSharedLog, mNetd, mBpfCoordinator,
+                mRoutingCoordinatorManager, mCallback, mTetherConfig, mAddressCoordinator,
+                mTetheringMetrics, mDependencies);
+
     }
 
     @Test
-    public void startsOutAvailable() {
+    public void startsOutAvailable() throws Exception {
         when(mDependencies.getIpNeighborMonitor(any(), any(), any()))
                 .thenReturn(mIpNeighborMonitor);
-        mIpServer = new IpServer(IFACE_NAME, mLooper.getLooper(), TETHERING_BLUETOOTH, mSharedLog,
-                mNetd, mBpfCoordinator, mCallback, mTetherConfig, mAddressCoordinator,
-                mTetheringMetrics, mDependencies);
+        mIpServer = createIpServer(TETHERING_BLUETOOTH);
         mIpServer.start();
         mLooper.dispatchAll();
         verify(mCallback).updateInterfaceState(
@@ -531,7 +592,7 @@
         InOrder inOrder = inOrder(mNetd, mBpfCoordinator);
 
         // Add the forwarding pair <IFACE_NAME, UPSTREAM_IFACE>.
-        inOrder.verify(mBpfCoordinator).addUpstreamNameToLookupTable(UPSTREAM_IFINDEX,
+        inOrder.verify(mBpfCoordinator).maybeAddUpstreamToLookupTable(UPSTREAM_IFINDEX,
                 UPSTREAM_IFACE);
         inOrder.verify(mBpfCoordinator).maybeAttachProgram(IFACE_NAME, UPSTREAM_IFACE);
         inOrder.verify(mNetd).tetherAddForward(IFACE_NAME, UPSTREAM_IFACE);
@@ -553,7 +614,7 @@
         inOrder.verify(mNetd).tetherRemoveForward(IFACE_NAME, UPSTREAM_IFACE);
 
         // Add the forwarding pair <IFACE_NAME, UPSTREAM_IFACE2>.
-        inOrder.verify(mBpfCoordinator).addUpstreamNameToLookupTable(UPSTREAM_IFINDEX2,
+        inOrder.verify(mBpfCoordinator).maybeAddUpstreamToLookupTable(UPSTREAM_IFINDEX2,
                 UPSTREAM_IFACE2);
         inOrder.verify(mBpfCoordinator).maybeAttachProgram(IFACE_NAME, UPSTREAM_IFACE2);
         inOrder.verify(mNetd).tetherAddForward(IFACE_NAME, UPSTREAM_IFACE2);
@@ -578,7 +639,7 @@
 
         // Add the forwarding pair <IFACE_NAME, UPSTREAM_IFACE2> and expect that failed on
         // tetherAddForward.
-        inOrder.verify(mBpfCoordinator).addUpstreamNameToLookupTable(UPSTREAM_IFINDEX2,
+        inOrder.verify(mBpfCoordinator).maybeAddUpstreamToLookupTable(UPSTREAM_IFINDEX2,
                 UPSTREAM_IFACE2);
         inOrder.verify(mBpfCoordinator).maybeAttachProgram(IFACE_NAME, UPSTREAM_IFACE2);
         inOrder.verify(mNetd).tetherAddForward(IFACE_NAME, UPSTREAM_IFACE2);
@@ -606,7 +667,7 @@
 
         // Add the forwarding pair <IFACE_NAME, UPSTREAM_IFACE2> and expect that failed on
         // ipfwdAddInterfaceForward.
-        inOrder.verify(mBpfCoordinator).addUpstreamNameToLookupTable(UPSTREAM_IFINDEX2,
+        inOrder.verify(mBpfCoordinator).maybeAddUpstreamToLookupTable(UPSTREAM_IFINDEX2,
                 UPSTREAM_IFACE2);
         inOrder.verify(mBpfCoordinator).maybeAttachProgram(IFACE_NAME, UPSTREAM_IFACE2);
         inOrder.verify(mNetd).tetherAddForward(IFACE_NAME, UPSTREAM_IFACE2);
@@ -627,7 +688,12 @@
         inOrder.verify(mBpfCoordinator).maybeDetachProgram(IFACE_NAME, UPSTREAM_IFACE);
         inOrder.verify(mNetd).ipfwdRemoveInterfaceForward(IFACE_NAME, UPSTREAM_IFACE);
         inOrder.verify(mNetd).tetherRemoveForward(IFACE_NAME, UPSTREAM_IFACE);
-        inOrder.verify(mBpfCoordinator).tetherOffloadRuleClear(mIpServer);
+        inOrder.verify(mBpfCoordinator).updateAllIpv6Rules(
+                mIpServer, TEST_IFACE_PARAMS, NO_UPSTREAM, NO_PREFIXES);
+        // When tethering stops, upstream interface is set to zero and thus clearing all upstream
+        // rules. Downstream rules are needed to be cleared explicitly by calling
+        // BpfCoordinator#clearAllIpv6Rules in TetheredState#exit.
+        inOrder.verify(mBpfCoordinator).clearAllIpv6Rules(mIpServer);
         inOrder.verify(mNetd).tetherApplyDnsInterfaces();
         inOrder.verify(mNetd).tetherInterfaceRemove(IFACE_NAME);
         inOrder.verify(mNetd).networkRemoveInterface(INetd.LOCAL_NET_ID, IFACE_NAME);
@@ -813,8 +879,8 @@
 
     @Test
     public void doesNotStartDhcpServerIfDisabled() throws Exception {
-        initTetheredStateMachine(TETHERING_WIFI, UPSTREAM_IFACE, true /* usingLegacyDhcp */,
-                DEFAULT_USING_BPF_OFFLOAD);
+        initTetheredStateMachine(TETHERING_WIFI, UPSTREAM_IFACE, NO_ADDRESSES,
+                true /* usingLegacyDhcp */, DEFAULT_USING_BPF_OFFLOAD);
         dispatchTetherConnectionChanged(UPSTREAM_IFACE);
 
         verify(mDependencies, never()).makeDhcpServer(any(), any(), any());
@@ -919,11 +985,19 @@
                 TEST_IFACE_PARAMS.macAddr, ETH_P_IPV6, NetworkStackConstants.ETHER_MTU);
     }
 
+    private static long prefixToLong(IpPrefix prefix) {
+        return ByteBuffer.wrap(prefix.getRawAddress()).getLong();
+    }
+
     private <T> T verifyWithOrder(@Nullable InOrder inOrder, @NonNull T t) {
+        return verifyWithOrder(inOrder, t, times(1));
+    }
+
+    private <T> T verifyWithOrder(@Nullable InOrder inOrder, @NonNull T t, VerificationMode mode) {
         if (inOrder != null) {
-            return inOrder.verify(t);
+            return inOrder.verify(t, mode);
         } else {
-            return verify(t);
+            return verify(t, mode);
         }
     }
 
@@ -983,23 +1057,49 @@
         }
     }
 
-    private void verifyStartUpstreamIpv6Forwarding(@Nullable InOrder inOrder, int upstreamIfindex)
-            throws Exception {
+    private void verifyStartUpstreamIpv6Forwarding(@Nullable InOrder inOrder, int upstreamIfindex,
+            @NonNull Set<IpPrefix> upstreamPrefixes) throws Exception {
         if (!mBpfDeps.isAtLeastS()) return;
-        final TetherUpstream6Key key = new TetherUpstream6Key(TEST_IFACE_PARAMS.index,
-                TEST_IFACE_PARAMS.macAddr, 0);
-        final Tether6Value value = new Tether6Value(upstreamIfindex,
-                MacAddress.ALL_ZEROS_ADDRESS, MacAddress.ALL_ZEROS_ADDRESS,
-                ETH_P_IPV6, NetworkStackConstants.ETHER_MTU);
-        verifyWithOrder(inOrder, mBpfUpstream6Map).insertEntry(key, value);
+        ArrayMap<TetherUpstream6Key, Tether6Value> expected = new ArrayMap<>();
+        for (IpPrefix upstreamPrefix : upstreamPrefixes) {
+            long prefix64 = prefixToLong(upstreamPrefix);
+            final TetherUpstream6Key key = new TetherUpstream6Key(TEST_IFACE_PARAMS.index,
+                    TEST_IFACE_PARAMS.macAddr, prefix64);
+            final Tether6Value value = new Tether6Value(upstreamIfindex,
+                    MacAddress.ALL_ZEROS_ADDRESS, MacAddress.ALL_ZEROS_ADDRESS,
+                    ETH_P_IPV6, NetworkStackConstants.ETHER_MTU);
+            expected.put(key, value);
+        }
+        ArgumentCaptor<TetherUpstream6Key> keyCaptor =
+                ArgumentCaptor.forClass(TetherUpstream6Key.class);
+        ArgumentCaptor<Tether6Value> valueCaptor =
+                ArgumentCaptor.forClass(Tether6Value.class);
+        verifyWithOrder(inOrder, mBpfUpstream6Map, times(expected.size())).insertEntry(
+                keyCaptor.capture(), valueCaptor.capture());
+        List<TetherUpstream6Key> keys = keyCaptor.getAllValues();
+        List<Tether6Value> values = valueCaptor.getAllValues();
+        ArrayMap<TetherUpstream6Key, Tether6Value> captured = new ArrayMap<>();
+        for (int i = 0; i < keys.size(); i++) {
+            captured.put(keys.get(i), values.get(i));
+        }
+        assertEquals(expected, captured);
     }
 
-    private void verifyStopUpstreamIpv6Forwarding(@Nullable InOrder inOrder)
-            throws Exception {
+    private void verifyStopUpstreamIpv6Forwarding(@Nullable InOrder inOrder,
+            @NonNull Set<IpPrefix> upstreamPrefixes) throws Exception {
         if (!mBpfDeps.isAtLeastS()) return;
-        final TetherUpstream6Key key = new TetherUpstream6Key(TEST_IFACE_PARAMS.index,
-                TEST_IFACE_PARAMS.macAddr, 0);
-        verifyWithOrder(inOrder, mBpfUpstream6Map).deleteEntry(key);
+        Set<TetherUpstream6Key> expected = new ArraySet<>();
+        for (IpPrefix upstreamPrefix : upstreamPrefixes) {
+            long prefix64 = prefixToLong(upstreamPrefix);
+            final TetherUpstream6Key key = new TetherUpstream6Key(TEST_IFACE_PARAMS.index,
+                    TEST_IFACE_PARAMS.macAddr, prefix64);
+            expected.add(key);
+        }
+        ArgumentCaptor<TetherUpstream6Key> keyCaptor =
+                ArgumentCaptor.forClass(TetherUpstream6Key.class);
+        verifyWithOrder(inOrder, mBpfUpstream6Map, times(expected.size())).deleteEntry(
+                keyCaptor.capture());
+        assertEquals(expected, new ArraySet(keyCaptor.getAllValues()));
     }
 
     private void verifyNoUpstreamIpv6ForwardingChange(@Nullable InOrder inOrder) throws Exception {
@@ -1040,8 +1140,8 @@
 
     @Test
     public void addRemoveipv6ForwardingRules() throws Exception {
-        initTetheredStateMachine(TETHERING_WIFI, UPSTREAM_IFACE, false /* usingLegacyDhcp */,
-                DEFAULT_USING_BPF_OFFLOAD);
+        initTetheredStateMachine(TETHERING_WIFI, UPSTREAM_IFACE, UPSTREAM_ADDRESSES,
+                false /* usingLegacyDhcp */, DEFAULT_USING_BPF_OFFLOAD);
 
         final int myIfindex = TEST_IFACE_PARAMS.index;
         final int notMyIfindex = myIfindex - 1;
@@ -1064,13 +1164,12 @@
         recvNewNeigh(notMyIfindex, neighA, NUD_REACHABLE, macA);
         verifyNoMoreInteractions(mBpfCoordinator, mNetd, mBpfDownstream6Map, mBpfUpstream6Map);
 
-        // Events on this interface are received and sent to netd.
+        // Events on this interface are received and sent to BpfCoordinator.
         recvNewNeigh(myIfindex, neighA, NUD_REACHABLE, macA);
         verify(mBpfCoordinator).addIpv6DownstreamRule(
                 mIpServer, makeDownstreamRule(UPSTREAM_IFINDEX, neighA, macA));
         verifyTetherOffloadRuleAdd(null,
                 UPSTREAM_IFINDEX, UPSTREAM_IFACE_PARAMS.macAddr, neighA, macA);
-        verifyStartUpstreamIpv6Forwarding(null, UPSTREAM_IFINDEX);
         resetNetdBpfMapAndCoordinator();
 
         recvNewNeigh(myIfindex, neighB, NUD_REACHABLE, macB);
@@ -1078,7 +1177,6 @@
                 mIpServer, makeDownstreamRule(UPSTREAM_IFINDEX, neighB, macB));
         verifyTetherOffloadRuleAdd(null,
                 UPSTREAM_IFINDEX, UPSTREAM_IFACE_PARAMS.macAddr, neighB, macB);
-        verifyNoUpstreamIpv6ForwardingChange(null);
         resetNetdBpfMapAndCoordinator();
 
         // Link-local and multicast neighbors are ignored.
@@ -1094,7 +1192,6 @@
                 mIpServer, makeDownstreamRule(UPSTREAM_IFINDEX, neighA, macNull));
         verifyTetherOffloadRuleRemove(null,
                 UPSTREAM_IFINDEX, UPSTREAM_IFACE_PARAMS.macAddr, neighA, macNull);
-        verifyNoUpstreamIpv6ForwardingChange(null);
         resetNetdBpfMapAndCoordinator();
 
         // A neighbor that is deleted causes the rule to be removed.
@@ -1103,102 +1200,134 @@
                 mIpServer,  makeDownstreamRule(UPSTREAM_IFINDEX, neighB, macNull));
         verifyTetherOffloadRuleRemove(null,
                 UPSTREAM_IFINDEX, UPSTREAM_IFACE_PARAMS.macAddr, neighB, macNull);
-        verifyStopUpstreamIpv6Forwarding(null);
         resetNetdBpfMapAndCoordinator();
 
-        // Upstream changes result in updating the rules.
+        // Upstream interface changes result in updating the rules.
         recvNewNeigh(myIfindex, neighA, NUD_REACHABLE, macA);
-        verifyStartUpstreamIpv6Forwarding(null, UPSTREAM_IFINDEX);
         recvNewNeigh(myIfindex, neighB, NUD_REACHABLE, macB);
         resetNetdBpfMapAndCoordinator();
 
         InOrder inOrder = inOrder(mNetd, mBpfDownstream6Map, mBpfUpstream6Map);
         LinkProperties lp = new LinkProperties();
         lp.setInterfaceName(UPSTREAM_IFACE2);
+        lp.setLinkAddresses(UPSTREAM_ADDRESSES);
         dispatchTetherConnectionChanged(UPSTREAM_IFACE2, lp, -1);
-        verify(mBpfCoordinator).tetherOffloadRuleUpdate(mIpServer, UPSTREAM_IFINDEX2);
+        verify(mBpfCoordinator).updateAllIpv6Rules(
+                mIpServer, TEST_IFACE_PARAMS, UPSTREAM_IFINDEX2, UPSTREAM_PREFIXES);
         verifyTetherOffloadRuleRemove(inOrder,
                 UPSTREAM_IFINDEX, UPSTREAM_IFACE_PARAMS.macAddr, neighA, macA);
         verifyTetherOffloadRuleRemove(inOrder,
                 UPSTREAM_IFINDEX, UPSTREAM_IFACE_PARAMS.macAddr, neighB, macB);
-        verifyStopUpstreamIpv6Forwarding(inOrder);
+        verifyStopUpstreamIpv6Forwarding(inOrder, UPSTREAM_PREFIXES);
+        verifyStartUpstreamIpv6Forwarding(inOrder, UPSTREAM_IFINDEX2, UPSTREAM_PREFIXES);
         verifyTetherOffloadRuleAdd(inOrder,
                 UPSTREAM_IFINDEX2, UPSTREAM_IFACE_PARAMS2.macAddr, neighA, macA);
-        verifyStartUpstreamIpv6Forwarding(inOrder, UPSTREAM_IFINDEX2);
         verifyTetherOffloadRuleAdd(inOrder,
                 UPSTREAM_IFINDEX2, UPSTREAM_IFACE_PARAMS2.macAddr, neighB, macB);
         verifyNoUpstreamIpv6ForwardingChange(inOrder);
         resetNetdBpfMapAndCoordinator();
 
+        // Upstream link addresses change result in updating the rules.
+        LinkProperties lp2 = new LinkProperties();
+        lp2.setInterfaceName(UPSTREAM_IFACE2);
+        lp2.setLinkAddresses(UPSTREAM_ADDRESSES2);
+        dispatchTetherConnectionChanged(UPSTREAM_IFACE2, lp2, -1);
+        verify(mBpfCoordinator).updateAllIpv6Rules(
+                mIpServer, TEST_IFACE_PARAMS, UPSTREAM_IFINDEX2, UPSTREAM_PREFIXES2);
+        verifyTetherOffloadRuleRemove(inOrder,
+                UPSTREAM_IFINDEX2, UPSTREAM_IFACE_PARAMS2.macAddr, neighA, macA);
+        verifyTetherOffloadRuleRemove(inOrder,
+                UPSTREAM_IFINDEX2, UPSTREAM_IFACE_PARAMS2.macAddr, neighB, macB);
+        verifyStopUpstreamIpv6Forwarding(inOrder, UPSTREAM_PREFIXES);
+        verifyStartUpstreamIpv6Forwarding(inOrder, UPSTREAM_IFINDEX2, UPSTREAM_PREFIXES2);
+        verifyTetherOffloadRuleAdd(inOrder,
+                UPSTREAM_IFINDEX2, UPSTREAM_IFACE_PARAMS2.macAddr, neighA, macA);
+        verifyTetherOffloadRuleAdd(inOrder,
+                UPSTREAM_IFINDEX2, UPSTREAM_IFACE_PARAMS2.macAddr, neighB, macB);
+        resetNetdBpfMapAndCoordinator();
+
         // When the upstream is lost, rules are removed.
         dispatchTetherConnectionChanged(null, null, 0);
-        // Clear function is called two times by:
+        // Upstream clear function is called two times by:
         // - processMessage CMD_TETHER_CONNECTION_CHANGED for the upstream is lost.
         // - processMessage CMD_IPV6_TETHER_UPDATE for the IPv6 upstream is lost.
         // See dispatchTetherConnectionChanged.
-        verify(mBpfCoordinator, times(2)).tetherOffloadRuleClear(mIpServer);
+        verify(mBpfCoordinator, times(2)).updateAllIpv6Rules(
+                mIpServer, TEST_IFACE_PARAMS, NO_UPSTREAM, NO_PREFIXES);
+        verifyStopUpstreamIpv6Forwarding(inOrder, UPSTREAM_PREFIXES2);
         verifyTetherOffloadRuleRemove(null,
                 UPSTREAM_IFINDEX2, UPSTREAM_IFACE_PARAMS2.macAddr, neighA, macA);
         verifyTetherOffloadRuleRemove(null,
                 UPSTREAM_IFINDEX2, UPSTREAM_IFACE_PARAMS2.macAddr, neighB, macB);
-        verifyStopUpstreamIpv6Forwarding(inOrder);
+        // Upstream lost doesn't clear the downstream rules from BpfCoordinator.
+        // Do that here.
+        recvDelNeigh(myIfindex, neighA, NUD_STALE, macA);
+        recvDelNeigh(myIfindex, neighB, NUD_STALE, macB);
+        verify(mBpfCoordinator).removeIpv6DownstreamRule(
+                mIpServer,  makeDownstreamRule(NO_UPSTREAM, neighA, macNull));
+        verify(mBpfCoordinator).removeIpv6DownstreamRule(
+                mIpServer,  makeDownstreamRule(NO_UPSTREAM, neighB, macNull));
         resetNetdBpfMapAndCoordinator();
 
-        // If the upstream is IPv4-only, no rules are added.
+        // If the upstream is IPv4-only, no IPv6 rules are added to BPF map.
         dispatchTetherConnectionChanged(UPSTREAM_IFACE);
         resetNetdBpfMapAndCoordinator();
         recvNewNeigh(myIfindex, neighA, NUD_REACHABLE, macA);
-        // Clear function is called by #updateIpv6ForwardingRules for the IPv6 upstream is lost.
-        verify(mBpfCoordinator).tetherOffloadRuleClear(mIpServer);
         verifyNoUpstreamIpv6ForwardingChange(null);
+        // Downstream rules are only added to BpfCoordinator but not BPF map.
+        verify(mBpfCoordinator).addIpv6DownstreamRule(
+                mIpServer, makeDownstreamRule(NO_UPSTREAM, neighA, macA));
+        verifyNeverTetherOffloadRuleAdd();
         verifyNoMoreInteractions(mBpfCoordinator, mNetd, mBpfDownstream6Map, mBpfUpstream6Map);
 
-        // Rules can be added again once upstream IPv6 connectivity is available.
+        // Rules can be added again once upstream IPv6 connectivity is available. The existing rules
+        // with an upstream of NO_UPSTREAM are reapplied.
         lp.setInterfaceName(UPSTREAM_IFACE);
         dispatchTetherConnectionChanged(UPSTREAM_IFACE, lp, -1);
+        verifyStartUpstreamIpv6Forwarding(null, UPSTREAM_IFINDEX, UPSTREAM_PREFIXES);
+        verify(mBpfCoordinator).addIpv6DownstreamRule(
+                mIpServer, makeDownstreamRule(UPSTREAM_IFINDEX, neighA, macA));
+        verifyTetherOffloadRuleAdd(null,
+                UPSTREAM_IFINDEX, UPSTREAM_IFACE_PARAMS.macAddr, neighA, macA);
         recvNewNeigh(myIfindex, neighB, NUD_REACHABLE, macB);
         verify(mBpfCoordinator).addIpv6DownstreamRule(
                 mIpServer, makeDownstreamRule(UPSTREAM_IFINDEX, neighB, macB));
         verifyTetherOffloadRuleAdd(null,
                 UPSTREAM_IFINDEX, UPSTREAM_IFACE_PARAMS.macAddr, neighB, macB);
-        verifyStartUpstreamIpv6Forwarding(null, UPSTREAM_IFINDEX);
-        verify(mBpfCoordinator, never()).addIpv6DownstreamRule(
-                mIpServer, makeDownstreamRule(UPSTREAM_IFINDEX, neighA, macA));
-        verifyNeverTetherOffloadRuleAdd(
-                UPSTREAM_IFINDEX, UPSTREAM_IFACE_PARAMS.macAddr, neighA, macA);
 
         // If upstream IPv6 connectivity is lost, rules are removed.
         resetNetdBpfMapAndCoordinator();
         dispatchTetherConnectionChanged(UPSTREAM_IFACE, null, 0);
-        verify(mBpfCoordinator).tetherOffloadRuleClear(mIpServer);
+        verify(mBpfCoordinator).updateAllIpv6Rules(
+                mIpServer, TEST_IFACE_PARAMS, NO_UPSTREAM, NO_PREFIXES);
         verifyTetherOffloadRuleRemove(null,
                 UPSTREAM_IFINDEX, UPSTREAM_IFACE_PARAMS.macAddr, neighB, macB);
-        verifyStopUpstreamIpv6Forwarding(null);
+        verifyStopUpstreamIpv6Forwarding(null, UPSTREAM_PREFIXES);
 
-        // When the interface goes down, rules are removed.
+        // When upstream IPv6 connectivity comes back, upstream rules are added and downstream rules
+        // are reapplied.
         lp.setInterfaceName(UPSTREAM_IFACE);
         dispatchTetherConnectionChanged(UPSTREAM_IFACE, lp, -1);
-        recvNewNeigh(myIfindex, neighA, NUD_REACHABLE, macA);
-        recvNewNeigh(myIfindex, neighB, NUD_REACHABLE, macB);
+        verifyStartUpstreamIpv6Forwarding(null, UPSTREAM_IFINDEX, UPSTREAM_PREFIXES);
         verify(mBpfCoordinator).addIpv6DownstreamRule(
                 mIpServer, makeDownstreamRule(UPSTREAM_IFINDEX, neighA, macA));
         verifyTetherOffloadRuleAdd(null,
                 UPSTREAM_IFINDEX, UPSTREAM_IFACE_PARAMS.macAddr, neighA, macA);
-        verifyStartUpstreamIpv6Forwarding(null, UPSTREAM_IFINDEX);
         verify(mBpfCoordinator).addIpv6DownstreamRule(
                 mIpServer, makeDownstreamRule(UPSTREAM_IFINDEX, neighB, macB));
         verifyTetherOffloadRuleAdd(null,
                 UPSTREAM_IFINDEX, UPSTREAM_IFACE_PARAMS.macAddr, neighB, macB);
         resetNetdBpfMapAndCoordinator();
 
+        // When the downstream interface goes down, rules are removed.
         mIpServer.stop();
         mLooper.dispatchAll();
-        verify(mBpfCoordinator).tetherOffloadRuleClear(mIpServer);
+        verify(mBpfCoordinator).clearAllIpv6Rules(mIpServer);
+        verifyStopUpstreamIpv6Forwarding(null, UPSTREAM_PREFIXES);
         verifyTetherOffloadRuleRemove(null,
                 UPSTREAM_IFINDEX, UPSTREAM_IFACE_PARAMS.macAddr, neighA, macA);
         verifyTetherOffloadRuleRemove(null,
                 UPSTREAM_IFINDEX, UPSTREAM_IFACE_PARAMS.macAddr, neighB, macB);
-        verifyStopUpstreamIpv6Forwarding(null);
         verify(mIpNeighborMonitor).stop();
         resetNetdBpfMapAndCoordinator();
     }
@@ -1219,8 +1348,8 @@
 
         // [1] Enable BPF offload.
         // A neighbor that is added or deleted causes the rule to be added or removed.
-        initTetheredStateMachine(TETHERING_WIFI, UPSTREAM_IFACE, false /* usingLegacyDhcp */,
-                true /* usingBpfOffload */);
+        initTetheredStateMachine(TETHERING_WIFI, UPSTREAM_IFACE, UPSTREAM_ADDRESSES,
+                false /* usingLegacyDhcp */, true /* usingBpfOffload */);
         resetNetdBpfMapAndCoordinator();
 
         recvNewNeigh(myIfindex, neigh, NUD_REACHABLE, macA);
@@ -1228,7 +1357,6 @@
                 mIpServer, makeDownstreamRule(UPSTREAM_IFINDEX, neigh, macA));
         verifyTetherOffloadRuleAdd(null,
                 UPSTREAM_IFINDEX, UPSTREAM_IFACE_PARAMS.macAddr, neigh, macA);
-        verifyStartUpstreamIpv6Forwarding(null, UPSTREAM_IFINDEX);
         resetNetdBpfMapAndCoordinator();
 
         recvDelNeigh(myIfindex, neigh, NUD_STALE, macA);
@@ -1236,30 +1364,43 @@
                 mIpServer, makeDownstreamRule(UPSTREAM_IFINDEX, neigh, macNull));
         verifyTetherOffloadRuleRemove(null,
                 UPSTREAM_IFINDEX, UPSTREAM_IFACE_PARAMS.macAddr, neigh, macNull);
-        verifyStopUpstreamIpv6Forwarding(null);
+        resetNetdBpfMapAndCoordinator();
+
+        // Upstream IPv6 connectivity change causes upstream rules change.
+        LinkProperties lp2 = new LinkProperties();
+        lp2.setInterfaceName(UPSTREAM_IFACE2);
+        lp2.setLinkAddresses(UPSTREAM_ADDRESSES2);
+        dispatchTetherConnectionChanged(UPSTREAM_IFACE2, lp2, 0);
+        verify(mBpfCoordinator).updateAllIpv6Rules(
+                mIpServer, TEST_IFACE_PARAMS, UPSTREAM_IFINDEX2, UPSTREAM_PREFIXES2);
+        verifyStartUpstreamIpv6Forwarding(null, UPSTREAM_IFINDEX2, UPSTREAM_PREFIXES2);
         resetNetdBpfMapAndCoordinator();
 
         // [2] Disable BPF offload.
         // A neighbor that is added or deleted doesn’t cause the rule to be added or removed.
-        initTetheredStateMachine(TETHERING_WIFI, UPSTREAM_IFACE, false /* usingLegacyDhcp */,
-                false /* usingBpfOffload */);
+        initTetheredStateMachine(TETHERING_WIFI, UPSTREAM_IFACE, UPSTREAM_ADDRESSES,
+                false /* usingLegacyDhcp */, false /* usingBpfOffload */);
         resetNetdBpfMapAndCoordinator();
 
         recvNewNeigh(myIfindex, neigh, NUD_REACHABLE, macA);
         verifyNeverTetherOffloadRuleAdd();
-        verifyNoUpstreamIpv6ForwardingChange(null);
         resetNetdBpfMapAndCoordinator();
 
         recvDelNeigh(myIfindex, neigh, NUD_STALE, macA);
         verifyNeverTetherOffloadRuleRemove();
+        resetNetdBpfMapAndCoordinator();
+
+        // Upstream IPv6 connectivity change doesn't cause the rule to be added or removed.
+        dispatchTetherConnectionChanged(UPSTREAM_IFACE2, lp2, 0);
         verifyNoUpstreamIpv6ForwardingChange(null);
+        verifyNeverTetherOffloadRuleRemove();
         resetNetdBpfMapAndCoordinator();
     }
 
     @Test
     public void doesNotStartIpNeighborMonitorIfBpfOffloadDisabled() throws Exception {
-        initTetheredStateMachine(TETHERING_WIFI, UPSTREAM_IFACE, false /* usingLegacyDhcp */,
-                false /* usingBpfOffload */);
+        initTetheredStateMachine(TETHERING_WIFI, UPSTREAM_IFACE, UPSTREAM_ADDRESSES,
+                false /* usingLegacyDhcp */, false /* usingBpfOffload */);
 
         // IP neighbor monitor doesn't start if BPF offload is disabled.
         verify(mIpNeighborMonitor, never()).start();
@@ -1541,8 +1682,8 @@
     // TODO: move to BpfCoordinatorTest once IpNeighborMonitor is migrated to BpfCoordinator.
     @Test
     public void addRemoveTetherClient() throws Exception {
-        initTetheredStateMachine(TETHERING_WIFI, UPSTREAM_IFACE, false /* usingLegacyDhcp */,
-                DEFAULT_USING_BPF_OFFLOAD);
+        initTetheredStateMachine(TETHERING_WIFI, UPSTREAM_IFACE, UPSTREAM_ADDRESSES,
+                false /* usingLegacyDhcp */, DEFAULT_USING_BPF_OFFLOAD);
 
         final int myIfindex = TEST_IFACE_PARAMS.index;
         final int notMyIfindex = myIfindex - 1;
diff --git a/Tethering/tests/unit/src/com/android/networkstack/tethering/BpfCoordinatorTest.java b/Tethering/tests/unit/src/com/android/networkstack/tethering/BpfCoordinatorTest.java
index 04eb430..7fbb670 100644
--- a/Tethering/tests/unit/src/com/android/networkstack/tethering/BpfCoordinatorTest.java
+++ b/Tethering/tests/unit/src/com/android/networkstack/tethering/BpfCoordinatorTest.java
@@ -55,6 +55,7 @@
 import static com.android.networkstack.tethering.BpfUtils.DOWNSTREAM;
 import static com.android.networkstack.tethering.BpfUtils.UPSTREAM;
 import static com.android.networkstack.tethering.TetheringConfiguration.DEFAULT_TETHER_OFFLOAD_POLL_INTERVAL_MS;
+import static com.android.testutils.MiscAsserts.assertSameElements;
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
@@ -73,6 +74,7 @@
 import static org.mockito.Mockito.inOrder;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
@@ -92,6 +94,7 @@
 import android.os.Build;
 import android.os.Handler;
 import android.os.test.TestLooper;
+import android.util.ArrayMap;
 import android.util.SparseArray;
 
 import androidx.annotation.NonNull;
@@ -136,16 +139,20 @@
 import org.mockito.Mock;
 import org.mockito.MockitoAnnotations;
 import org.mockito.MockitoSession;
+import org.mockito.verification.VerificationMode;
 
 import java.io.StringWriter;
 import java.net.Inet4Address;
 import java.net.Inet6Address;
 import java.net.InetAddress;
+import java.nio.ByteBuffer;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.HashMap;
 import java.util.LinkedHashMap;
+import java.util.List;
 import java.util.Map;
+import java.util.Set;
 
 @RunWith(AndroidJUnit4.class)
 @SmallTest
@@ -159,7 +166,7 @@
     private static final int TEST_NET_ID = 24;
     private static final int TEST_NET_ID2 = 25;
 
-    private static final int INVALID_IFINDEX = 0;
+    private static final int NO_UPSTREAM = 0;
     private static final int UPSTREAM_IFINDEX = 1001;
     private static final int UPSTREAM_XLAT_IFINDEX = 1002;
     private static final int UPSTREAM_IFINDEX2 = 1003;
@@ -169,6 +176,8 @@
     private static final String UPSTREAM_IFACE = "rmnet0";
     private static final String UPSTREAM_XLAT_IFACE = "v4-rmnet0";
     private static final String UPSTREAM_IFACE2 = "wlan0";
+    private static final String DOWNSTREAM_IFACE = "downstream1";
+    private static final String DOWNSTREAM_IFACE2 = "downstream2";
 
     private static final MacAddress DOWNSTREAM_MAC = MacAddress.fromString("12:34:56:78:90:ab");
     private static final MacAddress DOWNSTREAM_MAC2 = MacAddress.fromString("ab:90:78:56:34:12");
@@ -176,8 +185,17 @@
     private static final MacAddress MAC_A = MacAddress.fromString("00:00:00:00:00:0a");
     private static final MacAddress MAC_B = MacAddress.fromString("11:22:33:00:00:0b");
 
-    private static final InetAddress NEIGH_A = InetAddresses.parseNumericAddress("2001:db8::1");
-    private static final InetAddress NEIGH_B = InetAddresses.parseNumericAddress("2001:db8::2");
+    private static final IpPrefix UPSTREAM_PREFIX = new IpPrefix("2001:db8:0:1234::/64");
+    private static final IpPrefix UPSTREAM_PREFIX2 = new IpPrefix("2001:db8:0:abcd::/64");
+    private static final Set<IpPrefix> UPSTREAM_PREFIXES = Set.of(UPSTREAM_PREFIX);
+    private static final Set<IpPrefix> UPSTREAM_PREFIXES2 =
+            Set.of(UPSTREAM_PREFIX, UPSTREAM_PREFIX2);
+    private static final Set<IpPrefix> NO_PREFIXES = Set.of();
+
+    private static final InetAddress NEIGH_A =
+            InetAddresses.parseNumericAddress("2001:db8:0:1234::1");
+    private static final InetAddress NEIGH_B =
+            InetAddresses.parseNumericAddress("2001:db8:0:1234::2");
 
     private static final Inet4Address REMOTE_ADDR =
             (Inet4Address) InetAddresses.parseNumericAddress("140.112.8.116");
@@ -193,7 +211,6 @@
     private static final Inet4Address XLAT_LOCAL_IPV4ADDR =
             (Inet4Address) InetAddresses.parseNumericAddress("192.0.0.46");
     private static final IpPrefix NAT64_IP_PREFIX = new IpPrefix("64:ff9b::/96");
-    private static final IpPrefix IPV6_ZERO_PREFIX = new IpPrefix("::/64");
 
     // Generally, public port and private port are the same in the NAT conntrack message.
     // TODO: consider using different private port and public port for testing.
@@ -213,6 +230,11 @@
     private static final InterfaceParams UPSTREAM_IFACE_PARAMS2 = new InterfaceParams(
             UPSTREAM_IFACE2, UPSTREAM_IFINDEX2, MacAddress.fromString("44:55:66:00:00:0c"),
             NetworkStackConstants.ETHER_MTU);
+    private static final InterfaceParams DOWNSTREAM_IFACE_PARAMS = new InterfaceParams(
+            DOWNSTREAM_IFACE, DOWNSTREAM_IFINDEX, DOWNSTREAM_MAC, NetworkStackConstants.ETHER_MTU);
+    private static final InterfaceParams DOWNSTREAM_IFACE_PARAMS2 = new InterfaceParams(
+            DOWNSTREAM_IFACE2, DOWNSTREAM_IFINDEX2, DOWNSTREAM_MAC2,
+            NetworkStackConstants.ETHER_MTU);
 
     private static final Map<Integer, UpstreamInformation> UPSTREAM_INFORMATIONS = Map.of(
             UPSTREAM_IFINDEX, new UpstreamInformation(UPSTREAM_IFACE_PARAMS,
@@ -617,10 +639,14 @@
     }
 
     private <T> T verifyWithOrder(@Nullable InOrder inOrder, @NonNull T t) {
+        return verifyWithOrder(inOrder, t, times(1));
+    }
+
+    private <T> T verifyWithOrder(@Nullable InOrder inOrder, @NonNull T t, VerificationMode mode) {
         if (inOrder != null) {
-            return inOrder.verify(t);
+            return inOrder.verify(t, mode);
         } else {
-            return verify(t);
+            return verify(t, mode);
         }
     }
 
@@ -640,24 +666,6 @@
         }
     }
 
-    private void verifyStartUpstreamIpv6Forwarding(@Nullable InOrder inOrder, int downstreamIfIndex,
-            MacAddress downstreamMac, int upstreamIfindex) throws Exception {
-        if (!mDeps.isAtLeastS()) return;
-        final TetherUpstream6Key key = new TetherUpstream6Key(downstreamIfIndex, downstreamMac, 0);
-        final Tether6Value value = new Tether6Value(upstreamIfindex,
-                MacAddress.ALL_ZEROS_ADDRESS, MacAddress.ALL_ZEROS_ADDRESS,
-                ETH_P_IPV6, NetworkStackConstants.ETHER_MTU);
-        verifyWithOrder(inOrder, mBpfUpstream6Map).insertEntry(key, value);
-    }
-
-    private void verifyStopUpstreamIpv6Forwarding(@Nullable InOrder inOrder, int downstreamIfIndex,
-            MacAddress downstreamMac)
-            throws Exception {
-        if (!mDeps.isAtLeastS()) return;
-        final TetherUpstream6Key key = new TetherUpstream6Key(downstreamIfIndex, downstreamMac, 0);
-        verifyWithOrder(inOrder, mBpfUpstream6Map).deleteEntry(key);
-    }
-
     private void verifyNoUpstreamIpv6ForwardingChange(@Nullable InOrder inOrder) throws Exception {
         if (!mDeps.isAtLeastS()) return;
         if (inOrder != null) {
@@ -671,6 +679,35 @@
         }
     }
 
+    private void verifyAddUpstreamRule(@Nullable InOrder inOrder,
+            @NonNull Ipv6UpstreamRule rule) throws Exception {
+        if (!mDeps.isAtLeastS()) return;
+        verifyWithOrder(inOrder, mBpfUpstream6Map).insertEntry(
+                rule.makeTetherUpstream6Key(), rule.makeTether6Value());
+    }
+
+    private void verifyAddUpstreamRules(@Nullable InOrder inOrder,
+            @NonNull Set<Ipv6UpstreamRule> rules) throws Exception {
+        if (!mDeps.isAtLeastS()) return;
+        ArrayMap<TetherUpstream6Key, Tether6Value> expected = new ArrayMap<>();
+        for (Ipv6UpstreamRule rule : rules) {
+            expected.put(rule.makeTetherUpstream6Key(), rule.makeTether6Value());
+        }
+        ArgumentCaptor<TetherUpstream6Key> keyCaptor =
+                ArgumentCaptor.forClass(TetherUpstream6Key.class);
+        ArgumentCaptor<Tether6Value> valueCaptor =
+                ArgumentCaptor.forClass(Tether6Value.class);
+        verifyWithOrder(inOrder, mBpfUpstream6Map, times(expected.size())).insertEntry(
+                keyCaptor.capture(), valueCaptor.capture());
+        List<TetherUpstream6Key> keys = keyCaptor.getAllValues();
+        List<Tether6Value> values = valueCaptor.getAllValues();
+        ArrayMap<TetherUpstream6Key, Tether6Value> captured = new ArrayMap<>();
+        for (int i = 0; i < keys.size(); i++) {
+            captured.put(keys.get(i), values.get(i));
+        }
+        assertEquals(expected, captured);
+    }
+
     private void verifyAddDownstreamRule(@Nullable InOrder inOrder,
             @NonNull Ipv6DownstreamRule rule) throws Exception {
         if (mDeps.isAtLeastS()) {
@@ -681,6 +718,11 @@
         }
     }
 
+    private void verifyNeverAddUpstreamRule() throws Exception {
+        if (!mDeps.isAtLeastS()) return;
+        verify(mBpfUpstream6Map, never()).insertEntry(any(), any());
+    }
+
     private void verifyNeverAddDownstreamRule() throws Exception {
         if (mDeps.isAtLeastS()) {
             verify(mBpfDownstream6Map, never()).updateEntry(any(), any());
@@ -689,6 +731,27 @@
         }
     }
 
+    private void verifyRemoveUpstreamRule(@Nullable InOrder inOrder,
+            @NonNull final Ipv6UpstreamRule rule) throws Exception {
+        if (!mDeps.isAtLeastS()) return;
+        verifyWithOrder(inOrder, mBpfUpstream6Map).deleteEntry(
+                rule.makeTetherUpstream6Key());
+    }
+
+    private void verifyRemoveUpstreamRules(@Nullable InOrder inOrder,
+            @NonNull Set<Ipv6UpstreamRule> rules) throws Exception {
+        if (!mDeps.isAtLeastS()) return;
+        List<TetherUpstream6Key> expected = new ArrayList<>();
+        for (Ipv6UpstreamRule rule : rules) {
+            expected.add(rule.makeTetherUpstream6Key());
+        }
+        ArgumentCaptor<TetherUpstream6Key> keyCaptor =
+                ArgumentCaptor.forClass(TetherUpstream6Key.class);
+        verifyWithOrder(inOrder, mBpfUpstream6Map, times(expected.size())).deleteEntry(
+                keyCaptor.capture());
+        assertSameElements(expected, keyCaptor.getAllValues());
+    }
+
     private void verifyRemoveDownstreamRule(@Nullable InOrder inOrder,
             @NonNull final Ipv6DownstreamRule rule) throws Exception {
         if (mDeps.isAtLeastS()) {
@@ -699,6 +762,11 @@
         }
     }
 
+    private void verifyNeverRemoveUpstreamRule() throws Exception {
+        if (!mDeps.isAtLeastS()) return;
+        verify(mBpfUpstream6Map, never()).deleteEntry(any());
+    }
+
     private void verifyNeverRemoveDownstreamRule() throws Exception {
         if (mDeps.isAtLeastS()) {
             verify(mBpfDownstream6Map, never()).deleteEntry(any());
@@ -763,24 +831,33 @@
 
         final String mobileIface = "rmnet_data0";
         final Integer mobileIfIndex = 100;
-        coordinator.addUpstreamNameToLookupTable(mobileIfIndex, mobileIface);
+        coordinator.maybeAddUpstreamToLookupTable(mobileIfIndex, mobileIface);
 
         // InOrder is required because mBpfStatsMap may be accessed by both
         // BpfCoordinator#tetherOffloadRuleAdd and BpfCoordinator#tetherOffloadGetAndClearStats.
         // The #verifyTetherOffloadGetAndClearStats can't distinguish who has ever called
         // mBpfStatsMap#getValue and get a wrong calling count which counts all.
-        final InOrder inOrder = inOrder(mNetd, mBpfDownstream6Map, mBpfLimitMap, mBpfStatsMap);
-        final Ipv6DownstreamRule rule = buildTestDownstreamRule(mobileIfIndex, NEIGH_A, MAC_A);
-        coordinator.addIpv6DownstreamRule(mIpServer, rule);
-        verifyAddDownstreamRule(inOrder, rule);
+        final InOrder inOrder = inOrder(mNetd, mBpfUpstream6Map, mBpfDownstream6Map, mBpfLimitMap,
+                mBpfStatsMap);
+        final Ipv6UpstreamRule upstreamRule = buildTestUpstreamRule(
+                mobileIfIndex, DOWNSTREAM_IFINDEX, UPSTREAM_PREFIX, DOWNSTREAM_MAC);
+        final Ipv6DownstreamRule downstreamRule = buildTestDownstreamRule(
+                mobileIfIndex, NEIGH_A, MAC_A);
+        coordinator.updateAllIpv6Rules(
+                mIpServer, DOWNSTREAM_IFACE_PARAMS, mobileIfIndex, UPSTREAM_PREFIXES);
         verifyTetherOffloadSetInterfaceQuota(inOrder, mobileIfIndex, QUOTA_UNLIMITED,
                 true /* isInit */);
+        verifyAddUpstreamRule(inOrder, upstreamRule);
+        coordinator.addIpv6DownstreamRule(mIpServer, downstreamRule);
+        verifyAddDownstreamRule(inOrder, downstreamRule);
 
-        // Removing the last rule on current upstream immediately sends the cleanup stuff to netd.
+        // Removing the last rule on current upstream immediately sends the cleanup stuff to BPF.
         updateStatsEntryForTetherOffloadGetAndClearStats(
                 buildTestTetherStatsParcel(mobileIfIndex, 0, 0, 0, 0));
-        coordinator.removeIpv6DownstreamRule(mIpServer, rule);
-        verifyRemoveDownstreamRule(inOrder, rule);
+        coordinator.updateAllIpv6Rules(
+                mIpServer, DOWNSTREAM_IFACE_PARAMS, NO_UPSTREAM, NO_PREFIXES);
+        verifyRemoveDownstreamRule(inOrder, downstreamRule);
+        verifyRemoveUpstreamRule(inOrder, upstreamRule);
         verifyTetherOffloadGetAndClearStats(inOrder, mobileIfIndex);
     }
 
@@ -806,7 +883,7 @@
 
         final String mobileIface = "rmnet_data0";
         final Integer mobileIfIndex = 100;
-        coordinator.addUpstreamNameToLookupTable(mobileIfIndex, mobileIface);
+        coordinator.maybeAddUpstreamToLookupTable(mobileIfIndex, mobileIface);
 
         updateStatsEntriesAndWaitForUpdate(new TetherStatsParcel[] {
                 buildTestTetherStatsParcel(mobileIfIndex, 1000, 100, 2000, 200)});
@@ -847,8 +924,8 @@
 
         // Add interface name to lookup table. In realistic case, the upstream interface name will
         // be added by IpServer when IpServer has received with a new IPv6 upstream update event.
-        coordinator.addUpstreamNameToLookupTable(wlanIfIndex, wlanIface);
-        coordinator.addUpstreamNameToLookupTable(mobileIfIndex, mobileIface);
+        coordinator.maybeAddUpstreamToLookupTable(wlanIfIndex, wlanIface);
+        coordinator.maybeAddUpstreamToLookupTable(mobileIfIndex, mobileIface);
 
         // [1] Both interface stats are changed.
         // Setup the tether stats of wlan and mobile interface. Note that move forward the time of
@@ -912,7 +989,7 @@
 
         final String mobileIface = "rmnet_data0";
         final Integer mobileIfIndex = 100;
-        coordinator.addUpstreamNameToLookupTable(mobileIfIndex, mobileIface);
+        coordinator.maybeAddUpstreamToLookupTable(mobileIfIndex, mobileIface);
 
         // Verify that set quota to 0 will immediately triggers a callback.
         mTetherStatsProvider.onSetAlert(0);
@@ -978,10 +1055,10 @@
     }
 
     @NonNull
-    private static Ipv6UpstreamRule buildTestUpstreamRule(int upstreamIfindex) {
-        return new Ipv6UpstreamRule(upstreamIfindex, DOWNSTREAM_IFINDEX,
-                IPV6_ZERO_PREFIX, DOWNSTREAM_MAC, MacAddress.ALL_ZEROS_ADDRESS,
-                MacAddress.ALL_ZEROS_ADDRESS);
+    private static Ipv6UpstreamRule buildTestUpstreamRule(int upstreamIfindex,
+            int downstreamIfindex, @NonNull IpPrefix sourcePrefix, @NonNull MacAddress inDstMac) {
+        return new Ipv6UpstreamRule(upstreamIfindex, downstreamIfindex, sourcePrefix, inDstMac,
+                MacAddress.ALL_ZEROS_ADDRESS, MacAddress.ALL_ZEROS_ADDRESS);
     }
 
     @NonNull
@@ -1027,17 +1104,19 @@
 
         final String mobileIface = "rmnet_data0";
         final int mobileIfIndex = 100;
-        coordinator.addUpstreamNameToLookupTable(mobileIfIndex, mobileIface);
+        coordinator.maybeAddUpstreamToLookupTable(mobileIfIndex, mobileIface);
 
         // [1] Default limit.
         // Set the unlimited quota as default if the service has never applied a data limit for a
         // given upstream. Note that the data limit only be applied on an upstream which has rules.
-        final Ipv6DownstreamRule rule = buildTestDownstreamRule(mobileIfIndex, NEIGH_A, MAC_A);
-        final InOrder inOrder = inOrder(mNetd, mBpfDownstream6Map, mBpfLimitMap, mBpfStatsMap);
-        coordinator.addIpv6DownstreamRule(mIpServer, rule);
-        verifyAddDownstreamRule(inOrder, rule);
+        final Ipv6UpstreamRule rule = buildTestUpstreamRule(
+                mobileIfIndex, DOWNSTREAM_IFINDEX, UPSTREAM_PREFIX, DOWNSTREAM_MAC);
+        final InOrder inOrder = inOrder(mNetd, mBpfUpstream6Map, mBpfLimitMap, mBpfStatsMap);
+        coordinator.updateAllIpv6Rules(
+                mIpServer, DOWNSTREAM_IFACE_PARAMS, mobileIfIndex, UPSTREAM_PREFIXES);
         verifyTetherOffloadSetInterfaceQuota(inOrder, mobileIfIndex, QUOTA_UNLIMITED,
                 true /* isInit */);
+        verifyAddUpstreamRule(inOrder, rule);
         inOrder.verifyNoMoreInteractions();
 
         // [2] Specific limit.
@@ -1062,7 +1141,6 @@
         }
     }
 
-    // TODO: Test the case in which the rules are changed from different IpServer objects.
     @Test
     public void testSetDataLimitOnRule6Change() throws Exception {
         setupFunctioningNetdInterface();
@@ -1071,39 +1149,45 @@
 
         final String mobileIface = "rmnet_data0";
         final int mobileIfIndex = 100;
-        coordinator.addUpstreamNameToLookupTable(mobileIfIndex, mobileIface);
+        coordinator.maybeAddUpstreamToLookupTable(mobileIfIndex, mobileIface);
 
         // Applying a data limit to the current upstream does not take any immediate action.
         // The data limit could be only set on an upstream which has rules.
         final long limit = 12345;
-        final InOrder inOrder = inOrder(mNetd, mBpfDownstream6Map, mBpfLimitMap, mBpfStatsMap);
+        final InOrder inOrder = inOrder(mNetd, mBpfUpstream6Map, mBpfLimitMap, mBpfStatsMap);
         mTetherStatsProvider.onSetLimit(mobileIface, limit);
         waitForIdle();
         verifyNeverTetherOffloadSetInterfaceQuota(inOrder);
 
-        // Adding the first rule on current upstream immediately sends the quota to netd.
-        final Ipv6DownstreamRule ruleA = buildTestDownstreamRule(mobileIfIndex, NEIGH_A, MAC_A);
-        coordinator.addIpv6DownstreamRule(mIpServer, ruleA);
-        verifyAddDownstreamRule(inOrder, ruleA);
+        // Adding the first rule on current upstream immediately sends the quota to BPF.
+        final Ipv6UpstreamRule ruleA = buildTestUpstreamRule(
+                mobileIfIndex, DOWNSTREAM_IFINDEX, UPSTREAM_PREFIX, DOWNSTREAM_MAC);
+        coordinator.updateAllIpv6Rules(
+                mIpServer, DOWNSTREAM_IFACE_PARAMS, mobileIfIndex, UPSTREAM_PREFIXES);
         verifyTetherOffloadSetInterfaceQuota(inOrder, mobileIfIndex, limit, true /* isInit */);
+        verifyAddUpstreamRule(inOrder, ruleA);
         inOrder.verifyNoMoreInteractions();
 
-        // Adding the second rule on current upstream does not send the quota to netd.
-        final Ipv6DownstreamRule ruleB = buildTestDownstreamRule(mobileIfIndex, NEIGH_B, MAC_B);
-        coordinator.addIpv6DownstreamRule(mIpServer, ruleB);
-        verifyAddDownstreamRule(inOrder, ruleB);
+        // Adding the second rule on current upstream does not send the quota to BPF.
+        final Ipv6UpstreamRule ruleB = buildTestUpstreamRule(
+                mobileIfIndex, DOWNSTREAM_IFINDEX2, UPSTREAM_PREFIX, DOWNSTREAM_MAC2);
+        coordinator.updateAllIpv6Rules(
+                mIpServer2, DOWNSTREAM_IFACE_PARAMS2, mobileIfIndex, UPSTREAM_PREFIXES);
+        verifyAddUpstreamRule(inOrder, ruleB);
         verifyNeverTetherOffloadSetInterfaceQuota(inOrder);
 
-        // Removing the second rule on current upstream does not send the quota to netd.
-        coordinator.removeIpv6DownstreamRule(mIpServer, ruleB);
-        verifyRemoveDownstreamRule(inOrder, ruleB);
+        // Removing the second rule on current upstream does not send the quota to BPF.
+        coordinator.updateAllIpv6Rules(
+                mIpServer2, DOWNSTREAM_IFACE_PARAMS2, NO_UPSTREAM, NO_PREFIXES);
+        verifyRemoveUpstreamRule(inOrder, ruleB);
         verifyNeverTetherOffloadSetInterfaceQuota(inOrder);
 
-        // Removing the last rule on current upstream immediately sends the cleanup stuff to netd.
+        // Removing the last rule on current upstream immediately sends the cleanup stuff to BPF.
         updateStatsEntryForTetherOffloadGetAndClearStats(
                 buildTestTetherStatsParcel(mobileIfIndex, 0, 0, 0, 0));
-        coordinator.removeIpv6DownstreamRule(mIpServer, ruleA);
-        verifyRemoveDownstreamRule(inOrder, ruleA);
+        coordinator.updateAllIpv6Rules(
+                mIpServer, DOWNSTREAM_IFACE_PARAMS, NO_UPSTREAM, NO_PREFIXES);
+        verifyRemoveUpstreamRule(inOrder, ruleA);
         verifyTetherOffloadGetAndClearStats(inOrder, mobileIfIndex);
         inOrder.verifyNoMoreInteractions();
     }
@@ -1118,8 +1202,8 @@
         final String mobileIface = "rmnet_data0";
         final Integer ethIfIndex = 100;
         final Integer mobileIfIndex = 101;
-        coordinator.addUpstreamNameToLookupTable(ethIfIndex, ethIface);
-        coordinator.addUpstreamNameToLookupTable(mobileIfIndex, mobileIface);
+        coordinator.maybeAddUpstreamToLookupTable(ethIfIndex, ethIface);
+        coordinator.maybeAddUpstreamToLookupTable(mobileIfIndex, mobileIface);
 
         final InOrder inOrder = inOrder(mNetd, mBpfDownstream6Map, mBpfUpstream6Map, mBpfLimitMap,
                 mBpfStatsMap);
@@ -1133,20 +1217,28 @@
 
         // [1] Adding rules on the upstream Ethernet.
         // Note that the default data limit is applied after the first rule is added.
+        final Ipv6UpstreamRule ethernetUpstreamRule = buildTestUpstreamRule(
+                ethIfIndex, DOWNSTREAM_IFINDEX, UPSTREAM_PREFIX, DOWNSTREAM_MAC);
         final Ipv6DownstreamRule ethernetRuleA = buildTestDownstreamRule(
                 ethIfIndex, NEIGH_A, MAC_A);
         final Ipv6DownstreamRule ethernetRuleB = buildTestDownstreamRule(
                 ethIfIndex, NEIGH_B, MAC_B);
 
-        coordinator.addIpv6DownstreamRule(mIpServer, ethernetRuleA);
-        verifyAddDownstreamRule(inOrder, ethernetRuleA);
+        coordinator.updateAllIpv6Rules(
+                mIpServer, DOWNSTREAM_IFACE_PARAMS, ethIfIndex, UPSTREAM_PREFIXES);
         verifyTetherOffloadSetInterfaceQuota(inOrder, ethIfIndex, QUOTA_UNLIMITED,
                 true /* isInit */);
-        verifyStartUpstreamIpv6Forwarding(inOrder, DOWNSTREAM_IFINDEX, DOWNSTREAM_MAC, ethIfIndex);
+        verifyAddUpstreamRule(inOrder, ethernetUpstreamRule);
+        coordinator.addIpv6DownstreamRule(mIpServer, ethernetRuleA);
+        verifyAddDownstreamRule(inOrder, ethernetRuleA);
         coordinator.addIpv6DownstreamRule(mIpServer, ethernetRuleB);
         verifyAddDownstreamRule(inOrder, ethernetRuleB);
 
         // [2] Update the existing rules from Ethernet to cellular.
+        final Ipv6UpstreamRule mobileUpstreamRule = buildTestUpstreamRule(
+                mobileIfIndex, DOWNSTREAM_IFINDEX, UPSTREAM_PREFIX, DOWNSTREAM_MAC);
+        final Ipv6UpstreamRule mobileUpstreamRule2 = buildTestUpstreamRule(
+                mobileIfIndex, DOWNSTREAM_IFINDEX, UPSTREAM_PREFIX2, DOWNSTREAM_MAC);
         final Ipv6DownstreamRule mobileRuleA = buildTestDownstreamRule(
                 mobileIfIndex, NEIGH_A, MAC_A);
         final Ipv6DownstreamRule mobileRuleB = buildTestDownstreamRule(
@@ -1155,26 +1247,26 @@
                 buildTestTetherStatsParcel(ethIfIndex, 10, 20, 30, 40));
 
         // Update the existing rules for upstream changes. The rules are removed and re-added one
-        // by one for updating upstream interface index by #tetherOffloadRuleUpdate.
-        coordinator.tetherOffloadRuleUpdate(mIpServer, mobileIfIndex);
+        // by one for updating upstream interface index and prefixes by #tetherOffloadRuleUpdate.
+        coordinator.updateAllIpv6Rules(
+                mIpServer, DOWNSTREAM_IFACE_PARAMS, mobileIfIndex, UPSTREAM_PREFIXES2);
         verifyRemoveDownstreamRule(inOrder, ethernetRuleA);
         verifyRemoveDownstreamRule(inOrder, ethernetRuleB);
-        verifyStopUpstreamIpv6Forwarding(inOrder, DOWNSTREAM_IFINDEX, DOWNSTREAM_MAC);
+        verifyRemoveUpstreamRule(inOrder, ethernetUpstreamRule);
         verifyTetherOffloadGetAndClearStats(inOrder, ethIfIndex);
-        verifyAddDownstreamRule(inOrder, mobileRuleA);
         verifyTetherOffloadSetInterfaceQuota(inOrder, mobileIfIndex, QUOTA_UNLIMITED,
                 true /* isInit */);
-        verifyStartUpstreamIpv6Forwarding(inOrder, DOWNSTREAM_IFINDEX, DOWNSTREAM_MAC,
-                mobileIfIndex);
+        verifyAddUpstreamRules(inOrder, Set.of(mobileUpstreamRule, mobileUpstreamRule2));
+        verifyAddDownstreamRule(inOrder, mobileRuleA);
         verifyAddDownstreamRule(inOrder, mobileRuleB);
 
         // [3] Clear all rules for a given IpServer.
         updateStatsEntryForTetherOffloadGetAndClearStats(
                 buildTestTetherStatsParcel(mobileIfIndex, 50, 60, 70, 80));
-        coordinator.tetherOffloadRuleClear(mIpServer);
+        coordinator.clearAllIpv6Rules(mIpServer);
         verifyRemoveDownstreamRule(inOrder, mobileRuleA);
         verifyRemoveDownstreamRule(inOrder, mobileRuleB);
-        verifyStopUpstreamIpv6Forwarding(inOrder, DOWNSTREAM_IFINDEX, DOWNSTREAM_MAC);
+        verifyRemoveUpstreamRules(inOrder, Set.of(mobileUpstreamRule, mobileUpstreamRule2));
         verifyTetherOffloadGetAndClearStats(inOrder, mobileIfIndex);
 
         // [4] Force pushing stats update to verify that the last diff of stats is reported on all
@@ -1204,7 +1296,7 @@
         // The interface name lookup table can't be added.
         final String iface = "rmnet_data0";
         final Integer ifIndex = 100;
-        coordinator.addUpstreamNameToLookupTable(ifIndex, iface);
+        coordinator.maybeAddUpstreamToLookupTable(ifIndex, iface);
         assertEquals(0, coordinator.getInterfaceNamesForTesting().size());
 
         // The rule can't be added.
@@ -1230,14 +1322,15 @@
         assertEquals(1, rules.size());
 
         // The rule can't be cleared.
-        coordinator.tetherOffloadRuleClear(mIpServer);
+        coordinator.clearAllIpv6Rules(mIpServer);
         verifyNeverRemoveDownstreamRule();
         rules = coordinator.getIpv6DownstreamRulesForTesting().get(mIpServer);
         assertNotNull(rules);
         assertEquals(1, rules.size());
 
         // The rule can't be updated.
-        coordinator.tetherOffloadRuleUpdate(mIpServer, rule.upstreamIfindex + 1 /* new */);
+        coordinator.updateAllIpv6Rules(mIpServer, DOWNSTREAM_IFACE_PARAMS,
+                rule.upstreamIfindex + 1 /* new */, UPSTREAM_PREFIXES);
         verifyNeverRemoveDownstreamRule();
         verifyNeverAddDownstreamRule();
         rules = coordinator.getIpv6DownstreamRulesForTesting().get(mIpServer);
@@ -1533,12 +1626,12 @@
     //
     // @param coordinator BpfCoordinator instance.
     // @param upstreamIfindex upstream interface index. can be the following values.
-    //        INVALID_IFINDEX: no upstream interface
+    //        NO_UPSTREAM: no upstream interface
     //        UPSTREAM_IFINDEX: CELLULAR (raw ip interface)
     //        UPSTREAM_IFINDEX2: WIFI (ethernet interface)
     private void setUpstreamInformationTo(final BpfCoordinator coordinator,
             @Nullable Integer upstreamIfindex) {
-        if (upstreamIfindex == INVALID_IFINDEX) {
+        if (upstreamIfindex == NO_UPSTREAM) {
             coordinator.updateUpstreamNetworkState(null);
             return;
         }
@@ -1552,7 +1645,7 @@
         // interface index.
         doReturn(upstreamInfo.interfaceParams).when(mDeps).getInterfaceParams(
                 upstreamInfo.interfaceParams.name);
-        coordinator.addUpstreamNameToLookupTable(upstreamInfo.interfaceParams.index,
+        coordinator.maybeAddUpstreamToLookupTable(upstreamInfo.interfaceParams.index,
                 upstreamInfo.interfaceParams.name);
 
         final LinkProperties lp = new LinkProperties();
@@ -1677,19 +1770,23 @@
     public void testAddDevMapRule6() throws Exception {
         final BpfCoordinator coordinator = makeBpfCoordinator();
 
-        coordinator.addUpstreamNameToLookupTable(UPSTREAM_IFINDEX, UPSTREAM_IFACE);
-        final Ipv6DownstreamRule ruleA = buildTestDownstreamRule(UPSTREAM_IFINDEX, NEIGH_A, MAC_A);
-        final Ipv6DownstreamRule ruleB = buildTestDownstreamRule(UPSTREAM_IFINDEX, NEIGH_B, MAC_B);
-
-        coordinator.addIpv6DownstreamRule(mIpServer, ruleA);
+        coordinator.maybeAddUpstreamToLookupTable(UPSTREAM_IFINDEX, UPSTREAM_IFACE);
+        coordinator.updateAllIpv6Rules(
+                mIpServer, DOWNSTREAM_IFACE_PARAMS, UPSTREAM_IFINDEX, UPSTREAM_PREFIXES);
         verify(mBpfDevMap).updateEntry(eq(new TetherDevKey(UPSTREAM_IFINDEX)),
                 eq(new TetherDevValue(UPSTREAM_IFINDEX)));
         verify(mBpfDevMap).updateEntry(eq(new TetherDevKey(DOWNSTREAM_IFINDEX)),
                 eq(new TetherDevValue(DOWNSTREAM_IFINDEX)));
         clearInvocations(mBpfDevMap);
 
-        coordinator.addIpv6DownstreamRule(mIpServer, ruleB);
-        verify(mBpfDevMap, never()).updateEntry(any(), any());
+        // Adding the second downstream, only the second downstream ifindex is added to DevMap,
+        // the existing upstream ifindex won't be added again.
+        coordinator.updateAllIpv6Rules(
+                mIpServer2, DOWNSTREAM_IFACE_PARAMS2, UPSTREAM_IFINDEX, UPSTREAM_PREFIXES);
+        verify(mBpfDevMap).updateEntry(eq(new TetherDevKey(DOWNSTREAM_IFINDEX2)),
+                eq(new TetherDevValue(DOWNSTREAM_IFINDEX2)));
+        verify(mBpfDevMap, never()).updateEntry(eq(new TetherDevKey(UPSTREAM_IFINDEX)),
+                eq(new TetherDevValue(UPSTREAM_IFINDEX)));
     }
 
     @Test
@@ -1966,6 +2063,11 @@
                 100 /* nonzero, CT_NEW */);
     }
 
+    private static long prefixToLong(IpPrefix prefix) {
+        byte[] prefixBytes = Arrays.copyOf(prefix.getRawAddress(), 8);
+        return ByteBuffer.wrap(prefixBytes).getLong();
+    }
+
     void checkRule4ExistInUpstreamDownstreamMap() throws Exception {
         assertEquals(UPSTREAM4_RULE_VALUE_A, mBpfUpstream4Map.getValue(UPSTREAM4_RULE_KEY_A));
         assertEquals(DOWNSTREAM4_RULE_VALUE_A, mBpfDownstream4Map.getValue(
@@ -2083,7 +2185,7 @@
 
         // [3] Switch upstream from the first upstream (rawip, bpf supported) to no upstream. Clear
         // all rules.
-        setUpstreamInformationTo(coordinator, INVALID_IFINDEX);
+        setUpstreamInformationTo(coordinator, NO_UPSTREAM);
         checkRule4NotExistInUpstreamDownstreamMap();
 
         // Client information should be not deleted.
@@ -2150,13 +2252,15 @@
     public void testIpv6ForwardingRuleToString() throws Exception {
         final Ipv6DownstreamRule downstreamRule = buildTestDownstreamRule(UPSTREAM_IFINDEX, NEIGH_A,
                 MAC_A);
-        assertEquals("upstreamIfindex: 1001, downstreamIfindex: 2001, address: 2001:db8::1, "
+        assertEquals("upstreamIfindex: 1001, downstreamIfindex: 2001, address: 2001:db8:0:1234::1, "
                 + "srcMac: 12:34:56:78:90:ab, dstMac: 00:00:00:00:00:0a",
                 downstreamRule.toString());
-        final Ipv6UpstreamRule upstreamRule = buildTestUpstreamRule(UPSTREAM_IFINDEX);
-        assertEquals("upstreamIfindex: 1001, downstreamIfindex: 2001, sourcePrefix: ::/64, "
-                + "inDstMac: 12:34:56:78:90:ab, outSrcMac: 00:00:00:00:00:00, "
-                + "outDstMac: 00:00:00:00:00:00", upstreamRule.toString());
+        final Ipv6UpstreamRule upstreamRule = buildTestUpstreamRule(
+                UPSTREAM_IFINDEX, DOWNSTREAM_IFINDEX, UPSTREAM_PREFIX, DOWNSTREAM_MAC);
+        assertEquals("upstreamIfindex: 1001, downstreamIfindex: 2001, "
+                + "sourcePrefix: 2001:db8:0:1234::/64, inDstMac: 12:34:56:78:90:ab, "
+                + "outSrcMac: 00:00:00:00:00:00, outDstMac: 00:00:00:00:00:00",
+                upstreamRule.toString());
     }
 
     private void verifyDump(@NonNull final BpfCoordinator coordinator) {
@@ -2206,8 +2310,9 @@
         final Ipv6DownstreamRule rule = buildTestDownstreamRule(UPSTREAM_IFINDEX, NEIGH_A, MAC_A);
         mBpfDownstream6Map.insertEntry(rule.makeTetherDownstream6Key(), rule.makeTether6Value());
 
+        final long prefix64 = prefixToLong(UPSTREAM_PREFIX);
         final TetherUpstream6Key upstream6Key = new TetherUpstream6Key(DOWNSTREAM_IFINDEX,
-                DOWNSTREAM_MAC, 0);
+                DOWNSTREAM_MAC, prefix64);
         final Tether6Value upstream6Value = new Tether6Value(UPSTREAM_IFINDEX,
                 MacAddress.ALL_ZEROS_ADDRESS, MacAddress.ALL_ZEROS_ADDRESS,
                 ETH_P_IPV6, NetworkStackConstants.ETHER_MTU);
@@ -2221,7 +2326,7 @@
                         0L /* txPackets */, 0L /* txBytes */, 0L /* txErrors */));
 
         // dumpDevmap
-        coordinator.addUpstreamNameToLookupTable(UPSTREAM_IFINDEX, UPSTREAM_IFACE);
+        coordinator.maybeAddUpstreamToLookupTable(UPSTREAM_IFINDEX, UPSTREAM_IFACE);
         mBpfDevMap.insertEntry(
                 new TetherDevKey(UPSTREAM_IFINDEX),
                 new TetherDevValue(UPSTREAM_IFINDEX));
@@ -2390,7 +2495,7 @@
         // +-------+-------+-------+-------+-------+
 
         // [1] Mobile IPv4 only
-        coordinator.addUpstreamNameToLookupTable(UPSTREAM_IFINDEX, UPSTREAM_IFACE);
+        coordinator.maybeAddUpstreamToLookupTable(UPSTREAM_IFINDEX, UPSTREAM_IFACE);
         doReturn(UPSTREAM_IFACE_PARAMS).when(mDeps).getInterfaceParams(UPSTREAM_IFACE);
         final UpstreamNetworkState mobileIPv4UpstreamState = new UpstreamNetworkState(
                 buildUpstreamLinkProperties(UPSTREAM_IFACE,
@@ -2442,7 +2547,7 @@
         verifyIpv4Upstream(ipv4UpstreamIndices, interfaceNames);
 
         // Mobile IPv6 and xlat
-        // IpServer doesn't add xlat interface mapping via #addUpstreamNameToLookupTable on
+        // IpServer doesn't add xlat interface mapping via #maybeAddUpstreamToLookupTable on
         // S and T devices.
         coordinator.updateUpstreamNetworkState(mobile464xlatUpstreamState);
         // Upstream IPv4 address mapping is removed because xlat interface is not supported.
@@ -2457,7 +2562,7 @@
 
         // [6] Wifi IPv4 and IPv6
         // Expect that upstream index map is cleared because ether ip is not supported.
-        coordinator.addUpstreamNameToLookupTable(UPSTREAM_IFINDEX2, UPSTREAM_IFACE2);
+        coordinator.maybeAddUpstreamToLookupTable(UPSTREAM_IFINDEX2, UPSTREAM_IFACE2);
         doReturn(UPSTREAM_IFACE_PARAMS2).when(mDeps).getInterfaceParams(UPSTREAM_IFACE2);
         final UpstreamNetworkState wifiDualStackUpstreamState = new UpstreamNetworkState(
                 buildUpstreamLinkProperties(UPSTREAM_IFACE2,
diff --git a/Tethering/tests/unit/src/com/android/networkstack/tethering/PrivateAddressCoordinatorTest.java b/Tethering/tests/unit/src/com/android/networkstack/tethering/PrivateAddressCoordinatorTest.java
index 91b092a..6ebd6ae 100644
--- a/Tethering/tests/unit/src/com/android/networkstack/tethering/PrivateAddressCoordinatorTest.java
+++ b/Tethering/tests/unit/src/com/android/networkstack/tethering/PrivateAddressCoordinatorTest.java
@@ -126,16 +126,17 @@
 
         final LinkAddress newAddress = requestDownstreamAddress(mHotspotIpServer,
                 CONNECTIVITY_SCOPE_GLOBAL, false /* useLastAddress */);
-        final IpPrefix testDupRequest = asIpPrefix(newAddress);
-        assertNotEquals(hotspotPrefix, testDupRequest);
-        assertNotEquals(bluetoothPrefix, testDupRequest);
-        mPrivateAddressCoordinator.releaseDownstream(mHotspotIpServer);
+        final IpPrefix newHotspotPrefix = asIpPrefix(newAddress);
+        assertNotEquals(hotspotPrefix, newHotspotPrefix);
+        assertNotEquals(bluetoothPrefix, newHotspotPrefix);
 
         final LinkAddress usbAddress = requestDownstreamAddress(mUsbIpServer,
                 CONNECTIVITY_SCOPE_GLOBAL, false /* useLastAddress */);
         final IpPrefix usbPrefix = asIpPrefix(usbAddress);
         assertNotEquals(usbPrefix, bluetoothPrefix);
-        assertNotEquals(usbPrefix, hotspotPrefix);
+        assertNotEquals(usbPrefix, newHotspotPrefix);
+
+        mPrivateAddressCoordinator.releaseDownstream(mHotspotIpServer);
         mPrivateAddressCoordinator.releaseDownstream(mUsbIpServer);
     }
 
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 770507e..6eba590 100644
--- a/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringTest.java
+++ b/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringTest.java
@@ -142,6 +142,7 @@
 import android.net.NetworkCapabilities;
 import android.net.NetworkRequest;
 import android.net.RouteInfo;
+import android.net.RoutingCoordinatorManager;
 import android.net.TetherStatesParcel;
 import android.net.TetheredClient;
 import android.net.TetheredClient.AddressInfo;
@@ -191,6 +192,7 @@
 import com.android.internal.util.test.FakeSettingsProvider;
 import com.android.net.module.util.CollectionUtils;
 import com.android.net.module.util.InterfaceParams;
+import com.android.net.module.util.SdkUtil.LateSdk;
 import com.android.net.module.util.SharedLog;
 import com.android.net.module.util.ip.IpNeighborMonitor;
 import com.android.networkstack.apishim.common.BluetoothPanShim;
@@ -300,7 +302,7 @@
     // Like so many Android system APIs, these cannot be mocked because it is marked final.
     // We have to use the real versions.
     private final PersistableBundle mCarrierConfig = new PersistableBundle();
-    private final TestLooper mLooper = new TestLooper();
+    private TestLooper mLooper;
 
     private Vector<Intent> mIntents;
     private BroadcastInterceptingContext mServiceContext;
@@ -482,6 +484,12 @@
             return mEntitleMgr;
         }
 
+        @Nullable
+        @Override
+        public LateSdk<RoutingCoordinatorManager> getRoutingCoordinator(final Context context) {
+            return new LateSdk<>(null);
+        }
+
         @Override
         public TetheringConfiguration generateTetheringConfiguration(Context ctx, SharedLog log,
                 int subId) {
@@ -672,7 +680,14 @@
 
         mCm = spy(new TestConnectivityManager(mServiceContext, mock(IConnectivityManager.class)));
 
-        mTethering = makeTethering();
+        when(mPackageManager.hasSystemFeature(PackageManager.FEATURE_WIFI)).thenReturn(true);
+        when(mPackageManager.hasSystemFeature(PackageManager.FEATURE_WIFI_DIRECT)).thenReturn(true);
+    }
+
+    // In order to interact with syncSM from the test, tethering must be created in test thread.
+    private void initTetheringOnTestThread() throws Exception {
+        mLooper = new TestLooper();
+        mTethering = new Tethering(mTetheringDependencies);
         verify(mStatsManager, times(1)).registerNetworkStatsProvider(anyString(), any());
         verify(mNetd).registerUnsolicitedEventListener(any());
         verifyDefaultNetworkRequestFiled();
@@ -696,9 +711,6 @@
                     localOnlyCallbackCaptor.capture());
             mLocalOnlyHotspotCallback = localOnlyCallbackCaptor.getValue();
         }
-
-        when(mPackageManager.hasSystemFeature(PackageManager.FEATURE_WIFI)).thenReturn(true);
-        when(mPackageManager.hasSystemFeature(PackageManager.FEATURE_WIFI_DIRECT)).thenReturn(true);
     }
 
     private void setTetheringSupported(final boolean supported) {
@@ -730,10 +742,6 @@
         doReturn(upstreamState).when(mUpstreamNetworkMonitor).selectPreferredUpstreamType(any());
     }
 
-    private Tethering makeTethering() {
-        return new Tethering(mTetheringDependencies);
-    }
-
     private TetheringRequestParcel createTetheringRequestParcel(final int type) {
         return createTetheringRequestParcel(type, null, null, false, CONNECTIVITY_SCOPE_GLOBAL);
     }
@@ -877,6 +885,7 @@
 
     public void failingLocalOnlyHotspotLegacyApBroadcast(
             boolean emulateInterfaceStatusChanged) throws Exception {
+        initTetheringOnTestThread();
         // Emulate externally-visible WifiManager effects, causing the
         // per-interface state machine to start up, and telling us that
         // hotspot mode is to be started.
@@ -928,6 +937,7 @@
 
     @Test
     public void testUsbConfiguredBroadcastStartsTethering() throws Exception {
+        initTetheringOnTestThread();
         UpstreamNetworkState upstreamState = buildMobileIPv4UpstreamState();
         initTetheringUpstream(upstreamState);
         prepareUsbTethering();
@@ -1004,6 +1014,7 @@
 
     public void workingLocalOnlyHotspotEnrichedApBroadcast(
             boolean emulateInterfaceStatusChanged) throws Exception {
+        initTetheringOnTestThread();
         // Emulate externally-visible WifiManager effects, causing the
         // per-interface state machine to start up, and telling us that
         // hotspot mode is to be started.
@@ -1067,6 +1078,7 @@
 
     @Test
     public void workingMobileUsbTethering_IPv4() throws Exception {
+        initTetheringOnTestThread();
         UpstreamNetworkState upstreamState = buildMobileIPv4UpstreamState();
         runUsbTethering(upstreamState);
 
@@ -1081,7 +1093,8 @@
     }
 
     @Test
-    public void workingMobileUsbTethering_IPv4LegacyDhcp() {
+    public void workingMobileUsbTethering_IPv4LegacyDhcp() throws Exception {
+        initTetheringOnTestThread();
         when(mResources.getBoolean(R.bool.config_tether_enable_legacy_dhcp_server)).thenReturn(
                 true);
         sendConfigurationChanged();
@@ -1094,6 +1107,7 @@
 
     @Test
     public void workingMobileUsbTethering_IPv6() throws Exception {
+        initTetheringOnTestThread();
         UpstreamNetworkState upstreamState = buildMobileIPv6UpstreamState();
         runUsbTethering(upstreamState);
 
@@ -1109,6 +1123,7 @@
 
     @Test
     public void workingMobileUsbTethering_DualStack() throws Exception {
+        initTetheringOnTestThread();
         UpstreamNetworkState upstreamState = buildMobileDualStackUpstreamState();
         runUsbTethering(upstreamState);
 
@@ -1126,6 +1141,7 @@
 
     @Test
     public void workingMobileUsbTethering_MultipleUpstreams() throws Exception {
+        initTetheringOnTestThread();
         UpstreamNetworkState upstreamState = buildMobile464xlatUpstreamState();
         runUsbTethering(upstreamState);
 
@@ -1145,6 +1161,7 @@
 
     @Test
     public void workingMobileUsbTethering_v6Then464xlat() throws Exception {
+        initTetheringOnTestThread();
         when(mResources.getInteger(R.integer.config_tether_usb_functions)).thenReturn(
                 TetheringConfiguration.TETHER_USB_NCM_FUNCTION);
         when(mResources.getStringArray(R.array.config_tether_usb_regexs))
@@ -1186,6 +1203,7 @@
 
     @Test
     public void configTetherUpstreamAutomaticIgnoresConfigTetherUpstreamTypes() throws Exception {
+        initTetheringOnTestThread();
         when(mResources.getBoolean(R.bool.config_tether_upstream_automatic)).thenReturn(true);
         sendConfigurationChanged();
 
@@ -1234,6 +1252,7 @@
     }
 
     private void verifyAutomaticUpstreamSelection(boolean configAutomatic) throws Exception {
+        initTetheringOnTestThread();
         TestNetworkAgent mobile = new TestNetworkAgent(mCm, buildMobileDualStackUpstreamState());
         TestNetworkAgent wifi = new TestNetworkAgent(mCm, buildWifiUpstreamState());
         InOrder inOrder = inOrder(mCm, mUpstreamNetworkMonitor);
@@ -1333,6 +1352,7 @@
     @Test
     @IgnoreAfter(Build.VERSION_CODES.TIRAMISU)
     public void testLegacyUpstreamSelection() throws Exception {
+        initTetheringOnTestThread();
         TestNetworkAgent mobile = new TestNetworkAgent(mCm, buildMobileDualStackUpstreamState());
         TestNetworkAgent wifi = new TestNetworkAgent(mCm, buildWifiUpstreamState());
         InOrder inOrder = inOrder(mCm, mUpstreamNetworkMonitor);
@@ -1483,6 +1503,7 @@
     // +-------+-------+-------+-------+-------+
     //
     private void verifyChooseDunUpstreamByAutomaticMode(boolean configAutomatic) throws Exception {
+        initTetheringOnTestThread();
         // Enable automatic upstream selection.
         TestNetworkAgent mobile = new TestNetworkAgent(mCm, buildMobileDualStackUpstreamState());
         TestNetworkAgent wifi = new TestNetworkAgent(mCm, buildWifiUpstreamState());
@@ -1543,6 +1564,7 @@
     //
     @Test
     public void testChooseDunUpstreamByAutomaticMode_defaultNetworkWifi() throws Exception {
+        initTetheringOnTestThread();
         TestNetworkAgent mobile = new TestNetworkAgent(mCm, buildMobileDualStackUpstreamState());
         TestNetworkAgent wifi = new TestNetworkAgent(mCm, buildWifiUpstreamState());
         TestNetworkAgent dun = new TestNetworkAgent(mCm, buildDunUpstreamState());
@@ -1594,6 +1616,7 @@
     //
     @Test
     public void testChooseDunUpstreamByAutomaticMode_loseDefaultNetworkWifi() throws Exception {
+        initTetheringOnTestThread();
         TestNetworkAgent wifi = new TestNetworkAgent(mCm, buildWifiUpstreamState());
         TestNetworkAgent dun = new TestNetworkAgent(mCm, buildDunUpstreamState());
         final InOrder inOrder = inOrder(mCm, mUpstreamNetworkMonitor);
@@ -1635,6 +1658,7 @@
     //
     @Test
     public void testChooseDunUpstreamByAutomaticMode_defaultNetworkCell() throws Exception {
+        initTetheringOnTestThread();
         TestNetworkAgent mobile = new TestNetworkAgent(mCm, buildMobileDualStackUpstreamState());
         TestNetworkAgent dun = new TestNetworkAgent(mCm, buildDunUpstreamState());
         final InOrder inOrder = inOrder(mCm, mUpstreamNetworkMonitor);
@@ -1679,6 +1703,7 @@
     //
     @Test
     public void testChooseDunUpstreamByAutomaticMode_loseAndRegainDun() throws Exception {
+        initTetheringOnTestThread();
         TestNetworkAgent dun = new TestNetworkAgent(mCm, buildDunUpstreamState());
         final InOrder inOrder = inOrder(mCm, mUpstreamNetworkMonitor);
         setupDunUpstreamTest(true /* configAutomatic */, inOrder);
@@ -1720,6 +1745,7 @@
     @Test
     public void testChooseDunUpstreamByAutomaticMode_switchDefaultFromWifiToCell()
             throws Exception {
+        initTetheringOnTestThread();
         TestNetworkAgent mobile = new TestNetworkAgent(mCm, buildMobileDualStackUpstreamState());
         TestNetworkAgent wifi = new TestNetworkAgent(mCm, buildWifiUpstreamState());
         TestNetworkAgent dun = new TestNetworkAgent(mCm, buildDunUpstreamState());
@@ -1757,6 +1783,7 @@
     @Test
     @IgnoreAfter(Build.VERSION_CODES.TIRAMISU)
     public void testChooseDunUpstreamByLegacyMode() throws Exception {
+        initTetheringOnTestThread();
         // Enable Legacy upstream selection.
         TestNetworkAgent mobile = new TestNetworkAgent(mCm, buildMobileDualStackUpstreamState());
         TestNetworkAgent wifi = new TestNetworkAgent(mCm, buildWifiUpstreamState());
@@ -1849,6 +1876,7 @@
 
     @Test
     public void workingNcmTethering() throws Exception {
+        initTetheringOnTestThread();
         runNcmTethering();
 
         verify(mDhcpServer, timeout(DHCPSERVER_START_TIMEOUT_MS).times(1)).startWithCallbacks(
@@ -1856,7 +1884,8 @@
     }
 
     @Test
-    public void workingNcmTethering_LegacyDhcp() {
+    public void workingNcmTethering_LegacyDhcp() throws Exception {
+        initTetheringOnTestThread();
         when(mResources.getBoolean(R.bool.config_tether_enable_legacy_dhcp_server)).thenReturn(
                 true);
         sendConfigurationChanged();
@@ -1878,6 +1907,7 @@
     // TODO: Test with and without interfaceStatusChanged().
     @Test
     public void failingWifiTetheringLegacyApBroadcast() throws Exception {
+        initTetheringOnTestThread();
         when(mWifiManager.startTetheredHotspot(any(SoftApConfiguration.class))).thenReturn(true);
 
         // Emulate pressing the WiFi tethering button.
@@ -1906,6 +1936,7 @@
     // TODO: Test with and without interfaceStatusChanged().
     @Test
     public void workingWifiTetheringEnrichedApBroadcast() throws Exception {
+        initTetheringOnTestThread();
         when(mWifiManager.startTetheredHotspot(any(SoftApConfiguration.class))).thenReturn(true);
 
         // Emulate pressing the WiFi tethering button.
@@ -1954,6 +1985,7 @@
     // TODO: Test with and without interfaceStatusChanged().
     @Test
     public void failureEnablingIpForwarding() throws Exception {
+        initTetheringOnTestThread();
         when(mWifiManager.startTetheredHotspot(any(SoftApConfiguration.class))).thenReturn(true);
         doThrow(new RemoteException()).when(mNetd).ipfwdEnableForwarding(TETHERING_NAME);
 
@@ -2101,7 +2133,8 @@
     }
 
     @Test
-    public void testUntetherUsbWhenRestrictionIsOn() {
+    public void testUntetherUsbWhenRestrictionIsOn() throws Exception {
+        initTetheringOnTestThread();
         // Start usb tethering and check that usb interface is tethered.
         final UpstreamNetworkState upstreamState = buildMobileIPv4UpstreamState();
         runUsbTethering(upstreamState);
@@ -2278,6 +2311,7 @@
 
     @Test
     public void testRegisterTetheringEventCallback() throws Exception {
+        initTetheringOnTestThread();
         TestTetheringEventCallback callback = new TestTetheringEventCallback();
         TestTetheringEventCallback callback2 = new TestTetheringEventCallback();
         final TetheringInterface wifiIface = new TetheringInterface(
@@ -2342,6 +2376,7 @@
 
     @Test
     public void testReportFailCallbackIfOffloadNotSupported() throws Exception {
+        initTetheringOnTestThread();
         final UpstreamNetworkState upstreamState = buildMobileDualStackUpstreamState();
         TestTetheringEventCallback callback = new TestTetheringEventCallback();
         mTethering.registerTetheringEventCallback(callback);
@@ -2381,6 +2416,7 @@
 
     @Test
     public void testMultiSimAware() throws Exception {
+        initTetheringOnTestThread();
         final TetheringConfiguration initailConfig = mTethering.getTetheringConfiguration();
         assertEquals(INVALID_SUBSCRIPTION_ID, initailConfig.activeDataSubId);
 
@@ -2393,6 +2429,7 @@
 
     @Test
     public void testNoDuplicatedEthernetRequest() throws Exception {
+        initTetheringOnTestThread();
         final TetheredInterfaceRequest mockRequest = mock(TetheredInterfaceRequest.class);
         when(mEm.requestTetheredInterface(any(), any())).thenReturn(mockRequest);
         mTethering.startTethering(createTetheringRequestParcel(TETHERING_ETHERNET), TEST_CALLER_PKG,
@@ -2413,6 +2450,7 @@
 
     private void workingWifiP2pGroupOwner(
             boolean emulateInterfaceStatusChanged) throws Exception {
+        initTetheringOnTestThread();
         if (emulateInterfaceStatusChanged) {
             mTethering.interfaceStatusChanged(TEST_P2P_IFNAME, true);
         }
@@ -2452,6 +2490,7 @@
 
     private void workingWifiP2pGroupClient(
             boolean emulateInterfaceStatusChanged) throws Exception {
+        initTetheringOnTestThread();
         if (emulateInterfaceStatusChanged) {
             mTethering.interfaceStatusChanged(TEST_P2P_IFNAME, true);
         }
@@ -2492,6 +2531,7 @@
 
     private void workingWifiP2pGroupOwnerLegacyMode(
             boolean emulateInterfaceStatusChanged) throws Exception {
+        initTetheringOnTestThread();
         // change to legacy mode and update tethering information by chaning SIM
         when(mResources.getStringArray(R.array.config_tether_wifi_p2p_regexs))
                 .thenReturn(new String[]{});
@@ -2541,7 +2581,8 @@
     }
 
     @Test
-    public void testDataSaverChanged() {
+    public void testDataSaverChanged() throws Exception {
+        initTetheringOnTestThread();
         // Start Tethering.
         final UpstreamNetworkState upstreamState = buildMobileIPv4UpstreamState();
         runUsbTethering(upstreamState);
@@ -2596,6 +2637,7 @@
 
     @Test
     public void testMultipleStartTethering() throws Exception {
+        initTetheringOnTestThread();
         final LinkAddress serverLinkAddr = new LinkAddress("192.168.20.1/24");
         final LinkAddress clientLinkAddr = new LinkAddress("192.168.20.42/24");
         final String serverAddr = "192.168.20.1";
@@ -2639,6 +2681,7 @@
 
     @Test
     public void testRequestStaticIp() throws Exception {
+        initTetheringOnTestThread();
         when(mResources.getInteger(R.integer.config_tether_usb_functions)).thenReturn(
                 TetheringConfiguration.TETHER_USB_NCM_FUNCTION);
         when(mResources.getStringArray(R.array.config_tether_usb_regexs))
@@ -2668,7 +2711,8 @@
     }
 
     @Test
-    public void testUpstreamNetworkChanged() {
+    public void testUpstreamNetworkChanged() throws Exception {
+        initTetheringOnTestThread();
         final Tethering.TetherMainSM stateMachine = (Tethering.TetherMainSM)
                 mTetheringDependencies.mUpstreamNetworkMonitorSM;
         final InOrder inOrder = inOrder(mNotificationUpdater);
@@ -2710,7 +2754,8 @@
     }
 
     @Test
-    public void testUpstreamCapabilitiesChanged() {
+    public void testUpstreamCapabilitiesChanged() throws Exception {
+        initTetheringOnTestThread();
         final Tethering.TetherMainSM stateMachine = (Tethering.TetherMainSM)
                 mTetheringDependencies.mUpstreamNetworkMonitorSM;
         final InOrder inOrder = inOrder(mNotificationUpdater);
@@ -2745,6 +2790,7 @@
 
     @Test
     public void testUpstreamCapabilitiesChanged_startStopTethering() throws Exception {
+        initTetheringOnTestThread();
         final TestNetworkAgent wifi = new TestNetworkAgent(mCm, buildWifiUpstreamState());
 
         // Start USB tethering with no current upstream.
@@ -2766,6 +2812,7 @@
 
     @Test
     public void testDumpTetheringLog() throws Exception {
+        initTetheringOnTestThread();
         final FileDescriptor mockFd = mock(FileDescriptor.class);
         final PrintWriter mockPw = mock(PrintWriter.class);
         runUsbTethering(null);
@@ -2779,6 +2826,7 @@
 
     @Test
     public void testExemptFromEntitlementCheck() throws Exception {
+        initTetheringOnTestThread();
         setupForRequiredProvisioning();
         final TetheringRequestParcel wifiNotExemptRequest =
                 createTetheringRequestParcel(TETHERING_WIFI, null, null, false,
@@ -2869,31 +2917,42 @@
 
     @Test
     public void testHandleIpConflict() throws Exception {
+        initTetheringOnTestThread();
         final Network wifiNetwork = new Network(200);
         final Network[] allNetworks = { wifiNetwork };
         doReturn(allNetworks).when(mCm).getAllNetworks();
+        InOrder inOrder = inOrder(mUsbManager, mNetd);
         runUsbTethering(null);
+
+        inOrder.verify(mNetd).tetherInterfaceAdd(TEST_RNDIS_IFNAME);
+
         final ArgumentCaptor<InterfaceConfigurationParcel> ifaceConfigCaptor =
                 ArgumentCaptor.forClass(InterfaceConfigurationParcel.class);
         verify(mNetd).interfaceSetCfg(ifaceConfigCaptor.capture());
         final String ipv4Address = ifaceConfigCaptor.getValue().ipv4Addr;
         verify(mDhcpServer, timeout(DHCPSERVER_START_TIMEOUT_MS).times(1)).startWithCallbacks(
                 any(), any());
-        reset(mUsbManager);
 
         // Cause a prefix conflict by assigning a /30 out of the downstream's /24 to the upstream.
         updateV4Upstream(new LinkAddress(InetAddresses.parseNumericAddress(ipv4Address), 30),
                 wifiNetwork, TEST_WIFI_IFNAME, TRANSPORT_WIFI);
         // verify turn off usb tethering
-        verify(mUsbManager).setCurrentFunctions(UsbManager.FUNCTION_NONE);
+        inOrder.verify(mUsbManager).setCurrentFunctions(UsbManager.FUNCTION_NONE);
         sendUsbBroadcast(true, true, -1 /* function */);
         mLooper.dispatchAll();
+        inOrder.verify(mNetd).tetherInterfaceRemove(TEST_RNDIS_IFNAME);
+
         // verify restart usb tethering
-        verify(mUsbManager).setCurrentFunctions(UsbManager.FUNCTION_RNDIS);
+        inOrder.verify(mUsbManager).setCurrentFunctions(UsbManager.FUNCTION_RNDIS);
+
+        sendUsbBroadcast(true, true, TETHER_USB_RNDIS_FUNCTION);
+        mLooper.dispatchAll();
+        inOrder.verify(mNetd).tetherInterfaceAdd(TEST_RNDIS_IFNAME);
     }
 
     @Test
     public void testNoAddressAvailable() throws Exception {
+        initTetheringOnTestThread();
         final Network wifiNetwork = new Network(200);
         final Network btNetwork = new Network(201);
         final Network mobileNetwork = new Network(202);
@@ -2955,6 +3014,7 @@
 
     @Test
     public void testProvisioningNeededButUnavailable() throws Exception {
+        initTetheringOnTestThread();
         assertTrue(mTethering.isTetheringSupported());
         verify(mPackageManager, never()).getPackageInfo(PROVISIONING_APP_NAME[0], GET_ACTIVITIES);
 
@@ -2972,6 +3032,7 @@
 
     @Test
     public void testUpdateConnectedClients() throws Exception {
+        initTetheringOnTestThread();
         TestTetheringEventCallback callback = new TestTetheringEventCallback();
         runAsShell(NETWORK_SETTINGS, () -> {
             mTethering.registerTetheringEventCallback(callback);
@@ -3021,6 +3082,7 @@
     @Test
     @IgnoreUpTo(Build.VERSION_CODES.S_V2)
     public void testUpdateConnectedClientsForLocalOnlyHotspot() throws Exception {
+        initTetheringOnTestThread();
         TestTetheringEventCallback callback = new TestTetheringEventCallback();
         runAsShell(NETWORK_SETTINGS, () -> {
             mTethering.registerTetheringEventCallback(callback);
@@ -3053,6 +3115,7 @@
     @Test
     @IgnoreUpTo(Build.VERSION_CODES.S_V2)
     public void testConnectedClientsForSapAndLohsConcurrency() throws Exception {
+        initTetheringOnTestThread();
         TestTetheringEventCallback callback = new TestTetheringEventCallback();
         runAsShell(NETWORK_SETTINGS, () -> {
             mTethering.registerTetheringEventCallback(callback);
@@ -3178,6 +3241,7 @@
 
     @Test
     public void testBluetoothTethering() throws Exception {
+        initTetheringOnTestThread();
         // Switch to @IgnoreUpTo(Build.VERSION_CODES.S_V2) when it is available for AOSP.
         assumeTrue(isAtLeastT());
 
@@ -3214,6 +3278,7 @@
 
     @Test
     public void testBluetoothTetheringBeforeT() throws Exception {
+        initTetheringOnTestThread();
         // Switch to @IgnoreAfter(Build.VERSION_CODES.S_V2) when it is available for AOSP.
         assumeFalse(isAtLeastT());
 
@@ -3261,6 +3326,7 @@
 
     @Test
     public void testBluetoothServiceDisconnects() throws Exception {
+        initTetheringOnTestThread();
         final ResultListener result = new ResultListener(TETHER_ERROR_NO_ERROR);
         mockBluetoothSettings(true /* bluetoothOn */, true /* tetheringOn */);
         mTethering.startTethering(createTetheringRequestParcel(TETHERING_BLUETOOTH),
@@ -3415,6 +3481,7 @@
 
     @Test
     public void testUsbFunctionConfigurationChange() throws Exception {
+        initTetheringOnTestThread();
         // Run TETHERING_NCM.
         runNcmTethering();
         verify(mDhcpServer, timeout(DHCPSERVER_START_TIMEOUT_MS).times(1)).startWithCallbacks(
@@ -3473,6 +3540,7 @@
 
     @Test
     public void testTetheringSupported() throws Exception {
+        initTetheringOnTestThread();
         final ArraySet<Integer> expectedTypes = getAllSupportedTetheringTypes();
         // Check tethering is supported after initialization.
         TestTetheringEventCallback callback = new TestTetheringEventCallback();
@@ -3545,6 +3613,7 @@
 
     @Test
     public void testIpv4AddressForSapAndLohsConcurrency() throws Exception {
+        initTetheringOnTestThread();
         mTethering.interfaceStatusChanged(TEST_WLAN_IFNAME, true);
         sendWifiApStateChanged(WIFI_AP_STATE_ENABLED, TEST_WLAN_IFNAME, IFACE_IP_MODE_TETHERED);
 
diff --git a/Tethering/tests/unit/src/com/android/networkstack/tethering/util/StateMachineShimTest.kt b/Tethering/tests/unit/src/com/android/networkstack/tethering/util/StateMachineShimTest.kt
new file mode 100644
index 0000000..f8e98e3
--- /dev/null
+++ b/Tethering/tests/unit/src/com/android/networkstack/tethering/util/StateMachineShimTest.kt
@@ -0,0 +1,135 @@
+/**
+ * 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.networkstack.tethering.util
+
+import android.os.Looper
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.internal.util.State
+import com.android.networkstack.tethering.util.StateMachineShim.AsyncStateMachine
+import com.android.networkstack.tethering.util.StateMachineShim.Dependencies
+import com.android.networkstack.tethering.util.SyncStateMachine.StateInfo
+import kotlin.test.assertFailsWith
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.inOrder
+import org.mockito.Mockito.mock
+import org.mockito.Mockito.verify
+import org.mockito.Mockito.verifyNoMoreInteractions
+
+@RunWith(AndroidJUnit4::class)
+@SmallTest
+class StateMachineShimTest {
+    private val mSyncSM = mock(SyncStateMachine::class.java)
+    private val mAsyncSM = mock(AsyncStateMachine::class.java)
+    private val mState1 = mock(State::class.java)
+    private val mState2 = mock(State::class.java)
+
+    inner class MyDependencies() : Dependencies() {
+
+        override fun makeSyncStateMachine(name: String, thread: Thread) = mSyncSM
+
+        override fun makeAsyncStateMachine(name: String, looper: Looper) = mAsyncSM
+    }
+
+    @Test
+    fun testUsingSyncStateMachine() {
+        val inOrder = inOrder(mSyncSM, mAsyncSM)
+        val shimUsingSyncSM = StateMachineShim("ShimTest", null, MyDependencies())
+        shimUsingSyncSM.start(mState1)
+        inOrder.verify(mSyncSM).start(mState1)
+
+        val allStates = ArrayList<StateInfo>()
+        allStates.add(StateInfo(mState1, null))
+        allStates.add(StateInfo(mState2, mState1))
+        shimUsingSyncSM.addAllStates(allStates)
+        inOrder.verify(mSyncSM).addAllStates(allStates)
+
+        shimUsingSyncSM.transitionTo(mState1)
+        inOrder.verify(mSyncSM).transitionTo(mState1)
+
+        val what = 10
+        shimUsingSyncSM.sendMessage(what)
+        inOrder.verify(mSyncSM).processMessage(what, 0, 0, null)
+        val obj = Object()
+        shimUsingSyncSM.sendMessage(what, obj)
+        inOrder.verify(mSyncSM).processMessage(what, 0, 0, obj)
+        val arg1 = 11
+        shimUsingSyncSM.sendMessage(what, arg1)
+        inOrder.verify(mSyncSM).processMessage(what, arg1, 0, null)
+        val arg2 = 12
+        shimUsingSyncSM.sendMessage(what, arg1, arg2, obj)
+        inOrder.verify(mSyncSM).processMessage(what, arg1, arg2, obj)
+
+        assertFailsWith(IllegalStateException::class) {
+            shimUsingSyncSM.sendMessageDelayedToAsyncSM(what, 1000 /* delayMillis */)
+        }
+
+        assertFailsWith(IllegalStateException::class) {
+            shimUsingSyncSM.sendMessageAtFrontOfQueueToAsyncSM(what, arg1)
+        }
+
+        shimUsingSyncSM.sendSelfMessageToSyncSM(what, obj)
+        inOrder.verify(mSyncSM).sendSelfMessage(what, 0, 0, obj)
+
+        verifyNoMoreInteractions(mSyncSM, mAsyncSM)
+    }
+
+    @Test
+    fun testUsingAsyncStateMachine() {
+        val inOrder = inOrder(mSyncSM, mAsyncSM)
+        val shimUsingAsyncSM = StateMachineShim("ShimTest", mock(Looper::class.java),
+                MyDependencies())
+        shimUsingAsyncSM.start(mState1)
+        inOrder.verify(mAsyncSM).setInitialState(mState1)
+        inOrder.verify(mAsyncSM).start()
+
+        val allStates = ArrayList<StateInfo>()
+        allStates.add(StateInfo(mState1, null))
+        allStates.add(StateInfo(mState2, mState1))
+        shimUsingAsyncSM.addAllStates(allStates)
+        inOrder.verify(mAsyncSM).addState(mState1, null)
+        inOrder.verify(mAsyncSM).addState(mState2, mState1)
+
+        shimUsingAsyncSM.transitionTo(mState1)
+        inOrder.verify(mAsyncSM).transitionTo(mState1)
+
+        val what = 10
+        shimUsingAsyncSM.sendMessage(what)
+        inOrder.verify(mAsyncSM).sendMessage(what, 0, 0, null)
+        val obj = Object()
+        shimUsingAsyncSM.sendMessage(what, obj)
+        inOrder.verify(mAsyncSM).sendMessage(what, 0, 0, obj)
+        val arg1 = 11
+        shimUsingAsyncSM.sendMessage(what, arg1)
+        inOrder.verify(mAsyncSM).sendMessage(what, arg1, 0, null)
+        val arg2 = 12
+        shimUsingAsyncSM.sendMessage(what, arg1, arg2, obj)
+        inOrder.verify(mAsyncSM).sendMessage(what, arg1, arg2, obj)
+
+        shimUsingAsyncSM.sendMessageDelayedToAsyncSM(what, 1000 /* delayMillis */)
+        inOrder.verify(mAsyncSM).sendMessageDelayed(what, 1000)
+
+        shimUsingAsyncSM.sendMessageAtFrontOfQueueToAsyncSM(what, arg1)
+        inOrder.verify(mAsyncSM).sendMessageAtFrontOfQueueToAsyncSM(what, arg1)
+
+        assertFailsWith(IllegalStateException::class) {
+            shimUsingAsyncSM.sendSelfMessageToSyncSM(what, obj)
+        }
+
+        verifyNoMoreInteractions(mSyncSM, mAsyncSM)
+    }
+}
diff --git a/Tethering/tests/unit/src/com/android/networkstack/tethering/util/SyncStateMachineTest.kt b/Tethering/tests/unit/src/com/android/networkstack/tethering/util/SyncStateMachineTest.kt
new file mode 100644
index 0000000..3a57fdd
--- /dev/null
+++ b/Tethering/tests/unit/src/com/android/networkstack/tethering/util/SyncStateMachineTest.kt
@@ -0,0 +1,294 @@
+/**
+ * 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.networkstack.tethering.util
+
+import android.os.Message
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.internal.util.State
+import com.android.networkstack.tethering.util.SyncStateMachine.StateInfo
+import java.util.ArrayDeque
+import java.util.ArrayList
+import kotlin.test.assertFailsWith
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentMatchers.any
+import org.mockito.Mockito.inOrder
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.verifyNoMoreInteractions
+
+private const val MSG_INVALID = -1
+private const val MSG_1 = 1
+private const val MSG_2 = 2
+private const val MSG_3 = 3
+private const val MSG_4 = 4
+private const val MSG_5 = 5
+private const val MSG_6 = 6
+private const val MSG_7 = 7
+private const val ARG_1 = 100
+private const val ARG_2 = 200
+
+@RunWith(AndroidJUnit4::class)
+@SmallTest
+class SynStateMachineTest {
+    private val mState1 = spy(object : TestState(MSG_1) {})
+    private val mState2 = spy(object : TestState(MSG_2) {})
+    private val mState3 = spy(object : TestState(MSG_3) {})
+    private val mState4 = spy(object : TestState(MSG_4) {})
+    private val mState5 = spy(object : TestState(MSG_5) {})
+    private val mState6 = spy(object : TestState(MSG_6) {})
+    private val mState7 = spy(object : TestState(MSG_7) {})
+    private val mInOrder = inOrder(mState1, mState2, mState3, mState4, mState5, mState6, mState7)
+    // Lazy initialize to make sure running in test thread.
+    private val mSM by lazy {
+        SyncStateMachine("TestSyncStateMachine", Thread.currentThread(), true /* debug */)
+    }
+    private val mAllStates = ArrayList<StateInfo>()
+
+    private val mMsgProcessedResults = ArrayDeque<Pair<State, Int>>()
+
+    open inner class TestState(val expected: Int) : State() {
+        // Control destination state in obj field for testing.
+        override fun processMessage(msg: Message): Boolean {
+            mMsgProcessedResults.add(this to msg.what)
+            assertEquals(ARG_1, msg.arg1)
+            assertEquals(ARG_2, msg.arg2)
+
+            if (msg.what == expected) {
+                msg.obj?.let { mSM.transitionTo(it as State) }
+                return true
+            }
+
+            return false
+        }
+    }
+
+    private fun verifyNoMoreInteractions() {
+        verifyNoMoreInteractions(mState1, mState2, mState3, mState4, mState5, mState6)
+    }
+
+    private fun processMessage(what: Int, toState: State?) {
+        mSM.processMessage(what, ARG_1, ARG_2, toState)
+    }
+
+    private fun verifyMessageProcessedBy(what: Int, vararg processedStates: State) {
+        for (state in processedStates) {
+            // InOrder.verify can't check the Message content here because SyncSM will recycle the
+            // message after it's been processed. SyncSM reuses the same Message instance for all
+            // messages it processes. So, if using InOrder.verify to verify the content of a message
+            // after SyncSM has processed it, the content would be wrong.
+            mInOrder.verify(state).processMessage(any())
+            val (processedState, msgWhat) = mMsgProcessedResults.remove()
+            assertEquals(state, processedState)
+            assertEquals(what, msgWhat)
+        }
+        assertTrue(mMsgProcessedResults.isEmpty())
+    }
+
+    @Test
+    fun testInitialState() {
+        // mState1 -> initial
+        //    |
+        // mState2
+        mAllStates.add(StateInfo(mState1, null))
+        mAllStates.add(StateInfo(mState2, mState1))
+        mSM.addAllStates(mAllStates)
+
+        mSM.start(mState1)
+        mInOrder.verify(mState1).enter()
+        verifyNoMoreInteractions()
+    }
+
+    @Test
+    fun testStartFromLeafState() {
+        // mState1 -> initial
+        //    |
+        // mState2
+        //    |
+        // mState3
+        mAllStates.add(StateInfo(mState1, null))
+        mAllStates.add(StateInfo(mState2, mState1))
+        mAllStates.add(StateInfo(mState3, mState2))
+        mSM.addAllStates(mAllStates)
+
+        mSM.start(mState3)
+        mInOrder.verify(mState1).enter()
+        mInOrder.verify(mState2).enter()
+        mInOrder.verify(mState3).enter()
+        verifyNoMoreInteractions()
+    }
+
+    private fun verifyStart() {
+        mSM.addAllStates(mAllStates)
+        mSM.start(mState1)
+        mInOrder.verify(mState1).enter()
+        verifyNoMoreInteractions()
+    }
+
+    fun addState(state: State, parent: State? = null) {
+        mAllStates.add(StateInfo(state, parent))
+    }
+
+    @Test
+    fun testAddState() {
+        // Add duplicated states.
+        mAllStates.add(StateInfo(mState1, null))
+        mAllStates.add(StateInfo(mState1, null))
+        assertFailsWith(IllegalStateException::class) {
+            mSM.addAllStates(mAllStates)
+        }
+    }
+
+    @Test
+    fun testProcessMessage() {
+        // mState1
+        //    |
+        // mState2
+        addState(mState1)
+        addState(mState2, mState1)
+        verifyStart()
+
+        processMessage(MSG_1, null)
+        verifyMessageProcessedBy(MSG_1, mState1)
+        verifyNoMoreInteractions()
+    }
+
+    @Test
+    fun testTwoStates() {
+        // mState1 <-initial, mState2
+        addState(mState1)
+        addState(mState2)
+        verifyStart()
+
+        // Test transition to mState2
+        processMessage(MSG_1, mState2)
+        verifyMessageProcessedBy(MSG_1, mState1)
+        mInOrder.verify(mState1).exit()
+        mInOrder.verify(mState2).enter()
+        verifyNoMoreInteractions()
+
+        // If set destState to mState2 (current state), no state transition.
+        processMessage(MSG_2, mState2)
+        verifyMessageProcessedBy(MSG_2, mState2)
+        verifyNoMoreInteractions()
+    }
+
+    @Test
+    fun testTwoStateTrees() {
+        //    mState1 -> initial  mState4
+        //    /     \             /     \
+        // mState2 mState3     mState5 mState6
+        addState(mState1)
+        addState(mState2, mState1)
+        addState(mState3, mState1)
+        addState(mState4)
+        addState(mState5, mState4)
+        addState(mState6, mState4)
+        verifyStart()
+
+        //    mState1 -> current     mState4
+        //    /     \                /     \
+        // mState2 mState3 -> dest mState5 mState6
+        processMessage(MSG_1, mState3)
+        verifyMessageProcessedBy(MSG_1, mState1)
+        mInOrder.verify(mState3).enter()
+        verifyNoMoreInteractions()
+
+        //           mState1                     mState4
+        //           /     \                     /     \
+        // dest <- mState2 mState3 -> current mState5 mState6
+        processMessage(MSG_1, mState2)
+        verifyMessageProcessedBy(MSG_1, mState3, mState1)
+        mInOrder.verify(mState3).exit()
+        mInOrder.verify(mState2).enter()
+        verifyNoMoreInteractions()
+
+        //               mState1          mState4
+        //               /     \          /     \
+        // current <- mState2 mState3 mState5 mState6 -> dest
+        processMessage(MSG_2, mState6)
+        verifyMessageProcessedBy(MSG_2, mState2)
+        mInOrder.verify(mState2).exit()
+        mInOrder.verify(mState1).exit()
+        mInOrder.verify(mState4).enter()
+        mInOrder.verify(mState6).enter()
+        verifyNoMoreInteractions()
+    }
+
+    @Test
+    fun testMultiDepthTransition() {
+        //      mState1 -> current
+        //    |          \
+        //  mState2         mState6
+        //    |   \           |
+        //  mState3 mState5  mState7
+        //    |
+        //  mState4
+        addState(mState1)
+        addState(mState2, mState1)
+        addState(mState6, mState1)
+        addState(mState3, mState2)
+        addState(mState5, mState2)
+        addState(mState7, mState6)
+        addState(mState4, mState3)
+        verifyStart()
+
+        //      mState1 -> current
+        //    |          \
+        //  mState2         mState6
+        //    |   \           |
+        //  mState3 mState5  mState7
+        //    |
+        //  mState4 -> dest
+        processMessage(MSG_1, mState4)
+        verifyMessageProcessedBy(MSG_1, mState1)
+        mInOrder.verify(mState2).enter()
+        mInOrder.verify(mState3).enter()
+        mInOrder.verify(mState4).enter()
+        verifyNoMoreInteractions()
+
+        //            mState1
+        //        /            \
+        //  mState2             mState6
+        //    |   \                 \
+        //  mState3 mState5 -> dest  mState7
+        //    |
+        //  mState4 -> current
+        processMessage(MSG_1, mState5)
+        verifyMessageProcessedBy(MSG_1, mState4, mState3, mState2, mState1)
+        mInOrder.verify(mState4).exit()
+        mInOrder.verify(mState3).exit()
+        mInOrder.verify(mState5).enter()
+        verifyNoMoreInteractions()
+
+        //            mState1
+        //        /              \
+        //  mState2               mState6
+        //    |   \                    \
+        //  mState3 mState5 -> current  mState7 -> dest
+        //    |
+        //  mState4
+        processMessage(MSG_2, mState7)
+        verifyMessageProcessedBy(MSG_2, mState5, mState2)
+        mInOrder.verify(mState5).exit()
+        mInOrder.verify(mState2).exit()
+        mInOrder.verify(mState6).enter()
+        mInOrder.verify(mState7).enter()
+        verifyNoMoreInteractions()
+    }
+}
diff --git a/bpf_progs/Android.bp b/bpf_progs/Android.bp
index b3f8ed6..cdf47e7 100644
--- a/bpf_progs/Android.bp
+++ b/bpf_progs/Android.bp
@@ -45,6 +45,7 @@
         "com.android.tethering",
     ],
     visibility: [
+        "//packages/modules/Connectivity/DnsResolver",
         "//packages/modules/Connectivity/netd",
         "//packages/modules/Connectivity/service",
         "//packages/modules/Connectivity/service/native/libs/libclat",
diff --git a/bpf_progs/block.c b/bpf_progs/block.c
index d734b74..0a2b0b8 100644
--- a/bpf_progs/block.c
+++ b/bpf_progs/block.c
@@ -24,8 +24,8 @@
 
 #include "bpf_helpers.h"
 
-#define ALLOW 1
-#define DISALLOW 0
+static const int ALLOW = 1;
+static const int DISALLOW = 0;
 
 DEFINE_BPF_MAP_GRW(blocked_ports_map, ARRAY, int, uint64_t,
         1024 /* 64K ports -> 1024 u64s */, AID_SYSTEM)
@@ -57,14 +57,18 @@
     return ALLOW;
 }
 
-DEFINE_BPF_PROG_KVER("bind4/block_port", AID_ROOT, AID_SYSTEM,
-                     bind4_block_port, KVER(5, 4, 0))
+// the program need to be accessible/loadable by netd (from netd updatable plugin)
+#define DEFINE_NETD_RO_BPF_PROG(SECTION_NAME, the_prog, min_kver) \
+    DEFINE_BPF_PROG_EXT(SECTION_NAME, AID_ROOT, AID_ROOT, the_prog, min_kver, KVER_INF,  \
+                        BPFLOADER_MIN_VER, BPFLOADER_MAX_VER, MANDATORY, \
+                        "", "netd_readonly/", LOAD_ON_ENG, LOAD_ON_USER, LOAD_ON_USERDEBUG)
+
+DEFINE_NETD_RO_BPF_PROG("bind4/block_port", bind4_block_port, KVER_4_19)
 (struct bpf_sock_addr *ctx) {
     return block_port(ctx);
 }
 
-DEFINE_BPF_PROG_KVER("bind6/block_port", AID_ROOT, AID_SYSTEM,
-                     bind6_block_port, KVER(5, 4, 0))
+DEFINE_NETD_RO_BPF_PROG("bind6/block_port", bind6_block_port, KVER_4_19)
 (struct bpf_sock_addr *ctx) {
     return block_port(ctx);
 }
diff --git a/bpf_progs/bpf_net_helpers.h b/bpf_progs/bpf_net_helpers.h
index ed33cc9..f3c7de5 100644
--- a/bpf_progs/bpf_net_helpers.h
+++ b/bpf_progs/bpf_net_helpers.h
@@ -87,29 +87,18 @@
     if (skb->data_end - skb->data < len) bpf_skb_pull_data(skb, len);
 }
 
-// constants for passing in to 'bool egress'
-static const bool INGRESS = false;
-static const bool EGRESS = true;
+struct egress_bool { bool egress; };
+#define INGRESS ((struct egress_bool){ .egress = false })
+#define EGRESS ((struct egress_bool){ .egress = true })
 
-// constants for passing in to 'bool downstream'
-static const bool UPSTREAM = false;
-static const bool DOWNSTREAM = true;
+struct stream_bool { bool down; };
+#define UPSTREAM ((struct stream_bool){ .down = false })
+#define DOWNSTREAM ((struct stream_bool){ .down = true })
 
-// constants for passing in to 'bool is_ethernet'
-static const bool RAWIP = false;
-static const bool ETHER = true;
+struct rawip_bool { bool rawip; };
+#define ETHER ((struct rawip_bool){ .rawip = false })
+#define RAWIP ((struct rawip_bool){ .rawip = true })
 
-// constants for passing in to 'bool updatetime'
-static const bool NO_UPDATETIME = false;
-static const bool UPDATETIME = true;
-
-// constants for passing in to ignore_on_eng / ignore_on_user / ignore_on_userdebug
-// define's instead of static const due to tm-mainline-prod compiler static_assert limitations
-#define LOAD_ON_ENG false
-#define LOAD_ON_USER false
-#define LOAD_ON_USERDEBUG false
-#define IGNORE_ON_ENG true
-#define IGNORE_ON_USER true
-#define IGNORE_ON_USERDEBUG true
-
-#define KVER_4_14 KVER(4, 14, 0)
+struct updatetime_bool { bool updatetime; };
+#define NO_UPDATETIME ((struct updatetime_bool){ .updatetime = false })
+#define UPDATETIME ((struct updatetime_bool){ .updatetime = true })
diff --git a/bpf_progs/clatd.c b/bpf_progs/clatd.c
index 8f0ff84..addb02f 100644
--- a/bpf_progs/clatd.c
+++ b/bpf_progs/clatd.c
@@ -55,8 +55,10 @@
 DEFINE_BPF_MAP_GRW(clat_ingress6_map, HASH, ClatIngress6Key, ClatIngress6Value, 16, AID_SYSTEM)
 
 static inline __always_inline int nat64(struct __sk_buff* skb,
-                                        const bool is_ethernet,
-                                        const unsigned kver) {
+                                        const struct rawip_bool rawip,
+                                        const struct kver_uint kver) {
+    const bool is_ethernet = !rawip.rawip;
+
     // Require ethernet dst mac address to be our unicast address.
     if (is_ethernet && (skb->pkt_type != PACKET_HOST)) return TC_ACT_PIPE;
 
@@ -115,7 +117,7 @@
 
     if (proto == IPPROTO_FRAGMENT) {
         // Fragment handling requires bpf_skb_adjust_room which is 4.14+
-        if (kver < KVER_4_14) return TC_ACT_PIPE;
+        if (!KVER_IS_AT_LEAST(kver, 4, 14, 0)) return TC_ACT_PIPE;
 
         // Must have (ethernet and) ipv6 header and ipv6 fragment extension header
         if (data + l2_header_size + sizeof(*ip6) + sizeof(struct frag_hdr) > data_end)
@@ -233,7 +235,7 @@
     //
     // Note: we currently have no TreeHugger coverage for 4.9-T devices (there are no such
     // Pixel or cuttlefish devices), so likely you won't notice for months if this breaks...
-    if (kver >= KVER_4_14 && frag_off != htons(IP_DF)) {
+    if (KVER_IS_AT_LEAST(kver, 4, 14, 0) && frag_off != htons(IP_DF)) {
         // If we're converting an IPv6 Fragment, we need to trim off 8 more bytes
         // We're beyond recovery on error here... but hard to imagine how this could fail.
         if (bpf_skb_adjust_room(skb, -(__s32)sizeof(struct frag_hdr), BPF_ADJ_ROOM_NET, /*flags*/0))
diff --git a/bpf_progs/dscpPolicy.c b/bpf_progs/dscpPolicy.c
index 88b50b5..e845a69 100644
--- a/bpf_progs/dscpPolicy.c
+++ b/bpf_progs/dscpPolicy.c
@@ -222,7 +222,7 @@
 }
 
 DEFINE_BPF_PROG_KVER("schedcls/set_dscp_ether", AID_ROOT, AID_SYSTEM, schedcls_set_dscp_ether,
-                     KVER(5, 15, 0))
+                     KVER_5_15)
 (struct __sk_buff* skb) {
     if (skb->pkt_type != PACKET_HOST) return TC_ACT_PIPE;
 
diff --git a/bpf_progs/netd.c b/bpf_progs/netd.c
index c3258e9..f223dd1 100644
--- a/bpf_progs/netd.c
+++ b/bpf_progs/netd.c
@@ -59,18 +59,18 @@
 #define TCP_FLAG8_OFF (TCP_FLAG32_OFF + 1)
 
 // For maps netd does not need to access
-#define DEFINE_BPF_MAP_NO_NETD(the_map, TYPE, TypeOfKey, TypeOfValue, num_entries)      \
-    DEFINE_BPF_MAP_EXT(the_map, TYPE, TypeOfKey, TypeOfValue, num_entries,              \
-                       AID_ROOT, AID_NET_BW_ACCT, 0060, "fs_bpf_net_shared", "", false, \
-                       BPFLOADER_MIN_VER, BPFLOADER_MAX_VER, LOAD_ON_ENG,       \
-                       LOAD_ON_USER, LOAD_ON_USERDEBUG)
+#define DEFINE_BPF_MAP_NO_NETD(the_map, TYPE, TypeOfKey, TypeOfValue, num_entries) \
+    DEFINE_BPF_MAP_EXT(the_map, TYPE, TypeOfKey, TypeOfValue, num_entries,         \
+                       AID_ROOT, AID_NET_BW_ACCT, 0060, "fs_bpf_net_shared", "",   \
+                       PRIVATE, BPFLOADER_MIN_VER, BPFLOADER_MAX_VER,              \
+                       LOAD_ON_ENG, LOAD_ON_USER, LOAD_ON_USERDEBUG)
 
 // For maps netd only needs read only access to
-#define DEFINE_BPF_MAP_RO_NETD(the_map, TYPE, TypeOfKey, TypeOfValue, num_entries)         \
-    DEFINE_BPF_MAP_EXT(the_map, TYPE, TypeOfKey, TypeOfValue, num_entries,                 \
-                       AID_ROOT, AID_NET_BW_ACCT, 0460, "fs_bpf_netd_readonly", "", false, \
-                       BPFLOADER_MIN_VER, BPFLOADER_MAX_VER, LOAD_ON_ENG,       \
-                       LOAD_ON_USER, LOAD_ON_USERDEBUG)
+#define DEFINE_BPF_MAP_RO_NETD(the_map, TYPE, TypeOfKey, TypeOfValue, num_entries)  \
+    DEFINE_BPF_MAP_EXT(the_map, TYPE, TypeOfKey, TypeOfValue, num_entries,          \
+                       AID_ROOT, AID_NET_BW_ACCT, 0460, "fs_bpf_netd_readonly", "", \
+                       PRIVATE, BPFLOADER_MIN_VER, BPFLOADER_MAX_VER,               \
+                       LOAD_ON_ENG, LOAD_ON_USER, LOAD_ON_USERDEBUG)
 
 // For maps netd needs to be able to read and write
 #define DEFINE_BPF_MAP_RW_NETD(the_map, TYPE, TypeOfKey, TypeOfValue, num_entries) \
@@ -89,11 +89,11 @@
 DEFINE_BPF_MAP_RW_NETD(cookie_tag_map, HASH, uint64_t, UidTagValue, COOKIE_UID_MAP_SIZE)
 DEFINE_BPF_MAP_NO_NETD(uid_counterset_map, HASH, uint32_t, uint8_t, UID_COUNTERSET_MAP_SIZE)
 DEFINE_BPF_MAP_NO_NETD(app_uid_stats_map, HASH, uint32_t, StatsValue, APP_STATS_MAP_SIZE)
-DEFINE_BPF_MAP_RW_NETD(stats_map_A, HASH, StatsKey, StatsValue, STATS_MAP_SIZE)
+DEFINE_BPF_MAP_RO_NETD(stats_map_A, HASH, StatsKey, StatsValue, STATS_MAP_SIZE)
 DEFINE_BPF_MAP_RO_NETD(stats_map_B, HASH, StatsKey, StatsValue, STATS_MAP_SIZE)
 DEFINE_BPF_MAP_NO_NETD(iface_stats_map, HASH, uint32_t, StatsValue, IFACE_STATS_MAP_SIZE)
-DEFINE_BPF_MAP_NO_NETD(uid_owner_map, HASH, uint32_t, UidOwnerValue, UID_OWNER_MAP_SIZE)
-DEFINE_BPF_MAP_RW_NETD(uid_permission_map, HASH, uint32_t, uint8_t, UID_OWNER_MAP_SIZE)
+DEFINE_BPF_MAP_RO_NETD(uid_owner_map, HASH, uint32_t, UidOwnerValue, UID_OWNER_MAP_SIZE)
+DEFINE_BPF_MAP_RO_NETD(uid_permission_map, HASH, uint32_t, uint8_t, UID_OWNER_MAP_SIZE)
 DEFINE_BPF_MAP_NO_NETD(ingress_discard_map, HASH, IngressDiscardKey, IngressDiscardValue,
                        INGRESS_DISCARD_MAP_SIZE)
 
@@ -102,16 +102,18 @@
 
 // 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", "", false,
+                   AID_ROOT, AID_SYSTEM, 0060, "fs_bpf_net_shared", "", PRIVATE,
                    BPFLOADER_IGNORED_ON_VERSION, BPFLOADER_MAX_VER, LOAD_ON_ENG,
-                   IGNORE_ON_USER, LOAD_ON_USERDEBUG)
+                   LOAD_ON_USER, LOAD_ON_USERDEBUG)
 
-// A ring buffer on which packet information is pushed. This map will only be loaded
-// on eng and userdebug devices. User devices won't load this to save memory.
+// 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", "", false,
+                       AID_ROOT, AID_SYSTEM, 0060, "fs_bpf_net_shared", "", PRIVATE,
                        BPFLOADER_IGNORED_ON_VERSION, BPFLOADER_MAX_VER, LOAD_ON_ENG,
-                       IGNORE_ON_USER, LOAD_ON_USERDEBUG);
+                       LOAD_ON_USER, LOAD_ON_USERDEBUG);
+
+DEFINE_BPF_MAP_RO_NETD(data_saver_enabled_map, ARRAY, uint32_t, bool,
+                       DATA_SAVER_ENABLED_MAP_SIZE)
 
 // iptables xt_bpf programs need to be usable by both netd and netutils_wrappers
 // selinux contexts, because even non-xt_bpf iptables mutations are implemented as
@@ -128,8 +130,8 @@
 // which is loaded into netd and thus runs as netd uid/gid/selinux context)
 #define DEFINE_NETD_BPF_PROG_KVER_RANGE(SECTION_NAME, prog_uid, prog_gid, the_prog, minKV, maxKV) \
     DEFINE_BPF_PROG_EXT(SECTION_NAME, prog_uid, prog_gid, the_prog,                               \
-                        minKV, maxKV, BPFLOADER_MIN_VER, BPFLOADER_MAX_VER, false,                \
-                        "fs_bpf_netd_readonly", "", false, false, false)
+                        minKV, maxKV, BPFLOADER_MIN_VER, BPFLOADER_MAX_VER, MANDATORY,            \
+                        "fs_bpf_netd_readonly", "", LOAD_ON_ENG, LOAD_ON_USER, LOAD_ON_USERDEBUG)
 
 #define DEFINE_NETD_BPF_PROG_KVER(SECTION_NAME, prog_uid, prog_gid, the_prog, min_kv) \
     DEFINE_NETD_BPF_PROG_KVER_RANGE(SECTION_NAME, prog_uid, prog_gid, the_prog, min_kv, KVER_INF)
@@ -140,14 +142,8 @@
 // programs that only need to be usable by the system server
 #define DEFINE_SYS_BPF_PROG(SECTION_NAME, prog_uid, prog_gid, the_prog) \
     DEFINE_BPF_PROG_EXT(SECTION_NAME, prog_uid, prog_gid, the_prog, KVER_NONE, KVER_INF,  \
-                        BPFLOADER_MIN_VER, BPFLOADER_MAX_VER, false, "fs_bpf_net_shared", \
-                        "", false, false, false)
-
-static __always_inline int is_system_uid(uint32_t uid) {
-    // MIN_SYSTEM_UID is AID_ROOT == 0, so uint32_t is *always* >= 0
-    // MAX_SYSTEM_UID is AID_NOBODY == 9999, while AID_APP_START == 10000
-    return (uid < AID_APP_START);
-}
+                        BPFLOADER_MIN_VER, BPFLOADER_MAX_VER, MANDATORY, \
+                        "fs_bpf_net_shared", "", LOAD_ON_ENG, LOAD_ON_USER, LOAD_ON_USERDEBUG)
 
 /*
  * Note: this blindly assumes an MTU of 1500, and that packets > MTU are always TCP,
@@ -179,8 +175,8 @@
 #define DEFINE_UPDATE_STATS(the_stats_map, TypeOfKey)                                            \
     static __always_inline inline void update_##the_stats_map(const struct __sk_buff* const skb, \
                                                               const TypeOfKey* const key,        \
-                                                              const bool egress,                 \
-                                                              const unsigned kver) {             \
+                                                              const struct egress_bool egress,   \
+                                                              const struct kver_uint kver) {     \
         StatsValue* value = bpf_##the_stats_map##_lookup_elem(key);                              \
         if (!value) {                                                                            \
             StatsValue newValue = {};                                                            \
@@ -200,7 +196,7 @@
                 packets = (payload + mss - 1) / mss;                                             \
                 bytes = tcp_overhead * packets + payload;                                        \
             }                                                                                    \
-            if (egress) {                                                                        \
+            if (egress.egress) {                                                                 \
                 __sync_fetch_and_add(&value->txPackets, packets);                                \
                 __sync_fetch_and_add(&value->txBytes, bytes);                                    \
             } else {                                                                             \
@@ -220,7 +216,7 @@
                                                          const int L3_off,
                                                          void* const to,
                                                          const int len,
-                                                         const unsigned kver) {
+                                                         const struct kver_uint kver) {
     // 'kver' (here and throughout) is the compile time guaranteed minimum kernel version,
     // ie. we're building (a version of) the bpf program for kver (or newer!) kernels.
     //
@@ -237,16 +233,16 @@
     //
     // For similar reasons this will fail with non-offloaded VLAN tags on < 4.19 kernels,
     // since those extend the ethernet header from 14 to 18 bytes.
-    return kver >= KVER(4, 19, 0)
+    return KVER_IS_AT_LEAST(kver, 4, 19, 0)
         ? bpf_skb_load_bytes_relative(skb, L3_off, to, len, BPF_HDR_START_NET)
         : bpf_skb_load_bytes(skb, L3_off, to, len);
 }
 
 static __always_inline inline void do_packet_tracing(
-        const struct __sk_buff* const skb, const bool egress, const uint32_t uid,
-        const uint32_t tag, const bool enable_tracing, const unsigned kver) {
+        const struct __sk_buff* const skb, const struct egress_bool egress, const uint32_t uid,
+        const uint32_t tag, const bool enable_tracing, const struct kver_uint kver) {
     if (!enable_tracing) return;
-    if (kver < KVER(5, 8, 0)) return;
+    if (!KVER_IS_AT_LEAST(kver, 5, 8, 0)) return;
 
     uint32_t mapKey = 0;
     bool* traceConfig = bpf_packet_trace_enabled_map_lookup_elem(&mapKey);
@@ -318,8 +314,8 @@
     pkt->sport = sport;
     pkt->dport = dport;
 
-    pkt->egress = egress;
-    pkt->wakeup = !egress && (skb->mark & 0x80000000);  // Fwmark.ingress_cpu_wakeup
+    pkt->egress = egress.egress;
+    pkt->wakeup = !egress.egress && (skb->mark & 0x80000000);  // Fwmark.ingress_cpu_wakeup
     pkt->ipProto = proto;
     pkt->tcpFlags = flags;
     pkt->ipVersion = ipVersion;
@@ -327,8 +323,9 @@
     bpf_packet_trace_ringbuf_submit(pkt);
 }
 
-static __always_inline inline bool skip_owner_match(struct __sk_buff* skb, bool egress,
-                                                    const unsigned kver) {
+static __always_inline inline bool skip_owner_match(struct __sk_buff* skb,
+                                                    const struct egress_bool egress,
+                                                    const struct kver_uint kver) {
     uint32_t flag = 0;
     if (skb->protocol == htons(ETH_P_IP)) {
         uint8_t proto;
@@ -359,7 +356,7 @@
         return false;
     }
     // Always allow RST's, and additionally allow ingress FINs
-    return flag & (TCP_FLAG_RST | (egress ? 0 : TCP_FLAG_FIN));  // false on read failure
+    return flag & (TCP_FLAG_RST | (egress.egress ? 0 : TCP_FLAG_FIN));  // false on read failure
 }
 
 static __always_inline inline BpfConfig getConfig(uint32_t configKey) {
@@ -373,11 +370,11 @@
 }
 
 static __always_inline inline bool ingress_should_discard(struct __sk_buff* skb,
-                                                          const unsigned kver) {
+                                                          const struct kver_uint kver) {
     // Require 4.19, since earlier kernels don't have bpf_skb_load_bytes_relative() which
     // provides relative to L3 header reads.  Without that we could fetch the wrong bytes.
     // Additionally earlier bpf verifiers are much harder to please.
-    if (kver < KVER(4, 19, 0)) return false;
+    if (!KVER_IS_AT_LEAST(kver, 4, 19, 0)) return false;
 
     IngressDiscardKey k = {};
     if (skb->protocol == htons(ETH_P_IP)) {
@@ -401,13 +398,9 @@
     return true;  // disallowed interface
 }
 
-// DROP_IF_SET is set of rules that DROP if rule is globally enabled, and per-uid bit is set
-#define DROP_IF_SET (STANDBY_MATCH | OEM_DENY_1_MATCH | OEM_DENY_2_MATCH | OEM_DENY_3_MATCH)
-// DROP_IF_UNSET is set of rules that should DROP if globally enabled, and per-uid bit is NOT set
-#define DROP_IF_UNSET (DOZABLE_MATCH | POWERSAVE_MATCH | RESTRICTED_MATCH | LOW_POWER_STANDBY_MATCH)
-
 static __always_inline inline int bpf_owner_match(struct __sk_buff* skb, uint32_t uid,
-                                                  bool egress, const unsigned kver) {
+                                                  const struct egress_bool egress,
+                                                  const struct kver_uint kver) {
     if (is_system_uid(uid)) return PASS;
 
     if (skip_owner_match(skb, egress, kver)) return PASS;
@@ -418,14 +411,9 @@
     uint32_t uidRules = uidEntry ? uidEntry->rule : 0;
     uint32_t allowed_iif = uidEntry ? uidEntry->iif : 0;
 
-    // Warning: funky bit-wise arithmetic: in parallel, for all DROP_IF_SET/UNSET rules
-    // check whether the rules are globally enabled, and if so whether the rules are
-    // set/unset for the specific uid.  DROP if that is the case for ANY of the rules.
-    // We achieve this by masking out only the bits/rules we're interested in checking,
-    // and negating (via bit-wise xor) the bits/rules that should drop if unset.
-    if (enabledRules & (DROP_IF_SET | DROP_IF_UNSET) & (uidRules ^ DROP_IF_UNSET)) return DROP;
+    if (isBlockedByUidRules(enabledRules, uidRules)) return DROP;
 
-    if (!egress && skb->ifindex != 1) {
+    if (!egress.egress && skb->ifindex != 1) {
         if (ingress_should_discard(skb, kver)) return DROP;
         if (uidRules & IIF_MATCH) {
             if (allowed_iif && skb->ifindex != allowed_iif) {
@@ -445,8 +433,8 @@
 static __always_inline inline void update_stats_with_config(const uint32_t selectedMap,
                                                             const struct __sk_buff* const skb,
                                                             const StatsKey* const key,
-                                                            const bool egress,
-                                                            const unsigned kver) {
+                                                            const struct egress_bool egress,
+                                                            const struct kver_uint kver) {
     if (selectedMap == SELECT_MAP_A) {
         update_stats_map_A(skb, key, egress, kver);
     } else {
@@ -454,9 +442,10 @@
     }
 }
 
-static __always_inline inline int bpf_traffic_account(struct __sk_buff* skb, bool egress,
+static __always_inline inline int bpf_traffic_account(struct __sk_buff* skb,
+                                                      const struct egress_bool egress,
                                                       const bool enable_tracing,
-                                                      const unsigned kver) {
+                                                      const struct kver_uint kver) {
     uint32_t sock_uid = bpf_get_socket_uid(skb);
     uint64_t cookie = bpf_get_socket_cookie(skb);
     UidTagValue* utag = bpf_cookie_tag_map_lookup_elem(&cookie);
@@ -473,7 +462,7 @@
     // interface is accounted for and subject to usage restrictions.
     // CLAT IPv6 TX sockets are *always* tagged with CLAT uid, see tagSocketAsClat()
     // CLAT daemon receives via an untagged AF_PACKET socket.
-    if (egress && uid == AID_CLAT) return PASS;
+    if (egress.egress && uid == AID_CLAT) return PASS;
 
     int match = bpf_owner_match(skb, sock_uid, egress, kver);
 
@@ -489,7 +478,7 @@
     }
 
     // If an outbound packet is going to be dropped, we do not count that traffic.
-    if (egress && (match == DROP)) return DROP;
+    if (egress.egress && (match == DROP)) return DROP;
 
     StatsKey key = {.uid = uid, .tag = tag, .counterSet = 0, .ifaceIndex = skb->ifindex};
 
@@ -514,42 +503,66 @@
     return match;
 }
 
-DEFINE_BPF_PROG_EXT("cgroupskb/ingress/stats$trace", AID_ROOT, AID_SYSTEM,
-                    bpf_cgroup_ingress_trace, KVER(5, 8, 0), KVER_INF,
-                    BPFLOADER_IGNORED_ON_VERSION, BPFLOADER_MAX_VER, false,
-                    "fs_bpf_netd_readonly", "", false, true, false)
+// 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,
+                    "fs_bpf_netd_readonly", "",
+                    IGNORE_ON_ENG, LOAD_ON_USER, IGNORE_ON_USERDEBUG)
 (struct __sk_buff* skb) {
-    return bpf_traffic_account(skb, INGRESS, TRACE_ON, KVER(5, 8, 0));
+    return bpf_traffic_account(skb, INGRESS, TRACE_ON, KVER_5_8);
+}
+
+// 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,
+                    "fs_bpf_netd_readonly", "",
+                    LOAD_ON_ENG, IGNORE_ON_USER, LOAD_ON_USERDEBUG)
+(struct __sk_buff* skb) {
+    return bpf_traffic_account(skb, INGRESS, TRACE_ON, KVER_5_8);
 }
 
 DEFINE_NETD_BPF_PROG_KVER_RANGE("cgroupskb/ingress/stats$4_19", AID_ROOT, AID_SYSTEM,
-                                bpf_cgroup_ingress_4_19, KVER(4, 19, 0), KVER_INF)
+                                bpf_cgroup_ingress_4_19, KVER_4_19, KVER_INF)
 (struct __sk_buff* skb) {
-    return bpf_traffic_account(skb, INGRESS, TRACE_OFF, KVER(4, 19, 0));
+    return bpf_traffic_account(skb, INGRESS, TRACE_OFF, KVER_4_19);
 }
 
 DEFINE_NETD_BPF_PROG_KVER_RANGE("cgroupskb/ingress/stats$4_14", AID_ROOT, AID_SYSTEM,
-                                bpf_cgroup_ingress_4_14, KVER_NONE, KVER(4, 19, 0))
+                                bpf_cgroup_ingress_4_14, KVER_NONE, KVER_4_19)
 (struct __sk_buff* skb) {
     return bpf_traffic_account(skb, INGRESS, TRACE_OFF, KVER_NONE);
 }
 
-DEFINE_BPF_PROG_EXT("cgroupskb/egress/stats$trace", AID_ROOT, AID_SYSTEM,
-                    bpf_cgroup_egress_trace, KVER(5, 8, 0), KVER_INF,
-                    BPFLOADER_IGNORED_ON_VERSION, BPFLOADER_MAX_VER, false,
-                    "fs_bpf_netd_readonly", "", false, true, false)
+// 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,
+                    "fs_bpf_netd_readonly", "",
+                    LOAD_ON_ENG, IGNORE_ON_USER, LOAD_ON_USERDEBUG)
 (struct __sk_buff* skb) {
-    return bpf_traffic_account(skb, EGRESS, TRACE_ON, KVER(5, 8, 0));
+    return bpf_traffic_account(skb, EGRESS, TRACE_ON, KVER_5_8);
+}
+
+// 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,
+                    "fs_bpf_netd_readonly", "",
+                    LOAD_ON_ENG, IGNORE_ON_USER, LOAD_ON_USERDEBUG)
+(struct __sk_buff* skb) {
+    return bpf_traffic_account(skb, EGRESS, TRACE_ON, KVER_5_8);
 }
 
 DEFINE_NETD_BPF_PROG_KVER_RANGE("cgroupskb/egress/stats$4_19", AID_ROOT, AID_SYSTEM,
-                                bpf_cgroup_egress_4_19, KVER(4, 19, 0), KVER_INF)
+                                bpf_cgroup_egress_4_19, KVER_4_19, KVER_INF)
 (struct __sk_buff* skb) {
-    return bpf_traffic_account(skb, EGRESS, TRACE_OFF, KVER(4, 19, 0));
+    return bpf_traffic_account(skb, EGRESS, TRACE_OFF, KVER_4_19);
 }
 
 DEFINE_NETD_BPF_PROG_KVER_RANGE("cgroupskb/egress/stats$4_14", AID_ROOT, AID_SYSTEM,
-                                bpf_cgroup_egress_4_14, KVER_NONE, KVER(4, 19, 0))
+                                bpf_cgroup_egress_4_14, KVER_NONE, KVER_4_19)
 (struct __sk_buff* skb) {
     return bpf_traffic_account(skb, EGRESS, TRACE_OFF, KVER_NONE);
 }
@@ -624,9 +637,7 @@
     return BPF_NOMATCH;
 }
 
-DEFINE_NETD_BPF_PROG_KVER("cgroupsock/inet/create", AID_ROOT, AID_ROOT, inet_socket_create,
-                          KVER(4, 14, 0))
-(struct bpf_sock* sk) {
+static __always_inline inline uint8_t get_app_permissions() {
     uint64_t gid_uid = bpf_get_current_uid_gid();
     /*
      * A given app is guaranteed to have the same app ID in all the profiles in
@@ -636,13 +647,15 @@
      */
     uint32_t appId = (gid_uid & 0xffffffff) % AID_USER_OFFSET;  // == PER_USER_RANGE == 100000
     uint8_t* permissions = bpf_uid_permission_map_lookup_elem(&appId);
-    if (!permissions) {
-        // UID not in map. Default to just INTERNET permission.
-        return 1;
-    }
+    // if UID not in map, then default to just INTERNET permission.
+    return permissions ? *permissions : BPF_PERMISSION_INTERNET;
+}
 
+DEFINE_NETD_BPF_PROG_KVER("cgroupsock/inet/create", AID_ROOT, AID_ROOT, inet_socket_create,
+                          KVER_4_14)
+(struct bpf_sock* sk) {
     // A return value of 1 means allow, everything else means deny.
-    return (*permissions & BPF_PERMISSION_INTERNET) == BPF_PERMISSION_INTERNET;
+    return (get_app_permissions() & BPF_PERMISSION_INTERNET) ? 1 : 0;
 }
 
 LICENSE("Apache 2.0");
diff --git a/bpf_progs/netd.h b/bpf_progs/netd.h
index 6e9acaa..d1fc58d 100644
--- a/bpf_progs/netd.h
+++ b/bpf_progs/netd.h
@@ -16,6 +16,7 @@
 
 #pragma once
 
+#include <cutils/android_filesystem_config.h>
 #include <linux/if.h>
 #include <linux/if_ether.h>
 #include <linux/in.h>
@@ -125,6 +126,7 @@
 static const int UID_OWNER_MAP_SIZE = 4000;
 static const int INGRESS_DISCARD_MAP_SIZE = 100;
 static const int PACKET_TRACE_BUF_SIZE = 32 * 1024;
+static const int DATA_SAVER_ENABLED_MAP_SIZE = 1;
 
 #ifdef __cplusplus
 
@@ -171,6 +173,7 @@
 #define INGRESS_DISCARD_MAP_PATH BPF_NETD_PATH "map_netd_ingress_discard_map"
 #define PACKET_TRACE_RINGBUF_PATH BPF_NETD_PATH "map_netd_packet_trace_ringbuf"
 #define PACKET_TRACE_ENABLED_MAP_PATH BPF_NETD_PATH "map_netd_packet_trace_enabled_map"
+#define DATA_SAVER_ENABLED_MAP_PATH BPF_NETD_PATH "map_netd_data_saver_enabled_map"
 
 #endif // __cplusplus
 
@@ -190,7 +193,7 @@
     OEM_DENY_2_MATCH = (1 << 10),
     OEM_DENY_3_MATCH = (1 << 11),
 };
-// LINT.ThenChange(packages/modules/Connectivity/service/src/com/android/server/BpfNetMaps.java)
+// LINT.ThenChange(../framework/src/android/net/BpfNetMapsConstants.java)
 
 enum BpfPermissionMatch {
     BPF_PERMISSION_INTERNET = 1 << 2,
@@ -233,5 +236,27 @@
 #define UID_RULES_CONFIGURATION_KEY 0
 // Entry in the configuration map that stores which stats map is currently in use.
 #define CURRENT_STATS_MAP_CONFIGURATION_KEY 1
+// Entry in the data saver enabled map that stores whether data saver is enabled or not.
+#define DATA_SAVER_ENABLED_KEY 0
 
 #undef STRUCT_SIZE
+
+// DROP_IF_SET is set of rules that DROP if rule is globally enabled, and per-uid bit is set
+#define DROP_IF_SET (STANDBY_MATCH | OEM_DENY_1_MATCH | OEM_DENY_2_MATCH | OEM_DENY_3_MATCH)
+// DROP_IF_UNSET is set of rules that should DROP if globally enabled, and per-uid bit is NOT set
+#define DROP_IF_UNSET (DOZABLE_MATCH | POWERSAVE_MATCH | RESTRICTED_MATCH | LOW_POWER_STANDBY_MATCH)
+
+// Warning: funky bit-wise arithmetic: in parallel, for all DROP_IF_SET/UNSET rules
+// check whether the rules are globally enabled, and if so whether the rules are
+// set/unset for the specific uid.  DROP if that is the case for ANY of the rules.
+// We achieve this by masking out only the bits/rules we're interested in checking,
+// and negating (via bit-wise xor) the bits/rules that should drop if unset.
+static inline bool isBlockedByUidRules(BpfConfig enabledRules, uint32_t uidRules) {
+    return enabledRules & (DROP_IF_SET | DROP_IF_UNSET) & (uidRules ^ DROP_IF_UNSET);
+}
+
+static inline bool is_system_uid(uint32_t uid) {
+    // MIN_SYSTEM_UID is AID_ROOT == 0, so uint32_t is *always* >= 0
+    // MAX_SYSTEM_UID is AID_NOBODY == 9999, while AID_APP_START == 10000
+    return (uid < AID_APP_START);
+}
diff --git a/bpf_progs/offload.c b/bpf_progs/offload.c
index c752779..90f96a1 100644
--- a/bpf_progs/offload.c
+++ b/bpf_progs/offload.c
@@ -124,8 +124,12 @@
 DEFINE_BPF_MAP_GRW(tether_upstream6_map, HASH, TetherUpstream6Key, Tether6Value, 64,
                    TETHERING_GID)
 
-static inline __always_inline int do_forward6(struct __sk_buff* skb, const bool is_ethernet,
-        const bool downstream, const unsigned kver) {
+static inline __always_inline int do_forward6(struct __sk_buff* skb,
+                                              const struct rawip_bool rawip,
+                                              const struct stream_bool stream,
+                                              const struct kver_uint kver) {
+    const bool is_ethernet = !rawip.rawip;
+
     // Must be meta-ethernet IPv6 frame
     if (skb->protocol != htons(ETH_P_IPV6)) return TC_ACT_PIPE;
 
@@ -184,7 +188,7 @@
         TC_PUNT(NON_GLOBAL_DST);
 
     // In the upstream direction do not forward traffic within the same /64 subnet.
-    if (!downstream && (src32 == dst32) && (ip6->saddr.s6_addr32[1] == ip6->daddr.s6_addr32[1]))
+    if (!stream.down && (src32 == dst32) && (ip6->saddr.s6_addr32[1] == ip6->daddr.s6_addr32[1]))
         TC_PUNT(LOCAL_SRC_DST);
 
     TetherDownstream6Key kd = {
@@ -194,17 +198,18 @@
 
     TetherUpstream6Key ku = {
             .iif = skb->ifindex,
-            .src64 = 0,
+            // Retrieve the first 64 bits of the source IPv6 address in network order
+            .src64 = *(uint64_t*)&(ip6->saddr.s6_addr32[0]),
     };
-    if (is_ethernet) __builtin_memcpy(downstream ? kd.dstMac : ku.dstMac, eth->h_dest, ETH_ALEN);
+    if (is_ethernet) __builtin_memcpy(stream.down ? kd.dstMac : ku.dstMac, eth->h_dest, ETH_ALEN);
 
-    Tether6Value* v = downstream ? bpf_tether_downstream6_map_lookup_elem(&kd)
-                                 : bpf_tether_upstream6_map_lookup_elem(&ku);
+    Tether6Value* v = stream.down ? bpf_tether_downstream6_map_lookup_elem(&kd)
+                                  : bpf_tether_upstream6_map_lookup_elem(&ku);
 
     // If we don't find any offload information then simply let the core stack handle it...
     if (!v) return TC_ACT_PIPE;
 
-    uint32_t stat_and_limit_k = downstream ? skb->ifindex : v->oif;
+    uint32_t stat_and_limit_k = stream.down ? skb->ifindex : v->oif;
 
     TetherStatsValue* stat_v = bpf_tether_stats_map_lookup_elem(&stat_and_limit_k);
 
@@ -249,7 +254,7 @@
         // We do this even if TX interface is RAWIP and thus does not need an ethernet header,
         // because this is easier and the kernel will strip extraneous ethernet header.
         if (bpf_skb_change_head(skb, sizeof(struct ethhdr), /*flags*/ 0)) {
-            __sync_fetch_and_add(downstream ? &stat_v->rxErrors : &stat_v->txErrors, 1);
+            __sync_fetch_and_add(stream.down ? &stat_v->rxErrors : &stat_v->txErrors, 1);
             TC_PUNT(CHANGE_HEAD_FAILED);
         }
 
@@ -261,7 +266,7 @@
 
         // I do not believe this can ever happen, but keep the verifier happy...
         if (data + sizeof(struct ethhdr) + sizeof(*ip6) > data_end) {
-            __sync_fetch_and_add(downstream ? &stat_v->rxErrors : &stat_v->txErrors, 1);
+            __sync_fetch_and_add(stream.down ? &stat_v->rxErrors : &stat_v->txErrors, 1);
             TC_DROP(TOO_SHORT);
         }
     };
@@ -281,8 +286,8 @@
     // (-ENOTSUPP) if it isn't.
     bpf_csum_update(skb, 0xFFFF - ntohs(old_hl) + ntohs(new_hl));
 
-    __sync_fetch_and_add(downstream ? &stat_v->rxPackets : &stat_v->txPackets, packets);
-    __sync_fetch_and_add(downstream ? &stat_v->rxBytes : &stat_v->txBytes, L3_bytes);
+    __sync_fetch_and_add(stream.down ? &stat_v->rxPackets : &stat_v->txPackets, packets);
+    __sync_fetch_and_add(stream.down ? &stat_v->rxBytes : &stat_v->txBytes, L3_bytes);
 
     // Overwrite any mac header with the new one
     // For a rawip tx interface it will simply be a bunch of zeroes and later stripped.
@@ -324,26 +329,26 @@
 //
 // Hence, these mandatory (must load successfully) implementations for 4.14+ kernels:
 DEFINE_BPF_PROG_KVER("schedcls/tether_downstream6_rawip$4_14", TETHERING_UID, TETHERING_GID,
-                     sched_cls_tether_downstream6_rawip_4_14, KVER(4, 14, 0))
+                     sched_cls_tether_downstream6_rawip_4_14, KVER_4_14)
 (struct __sk_buff* skb) {
-    return do_forward6(skb, RAWIP, DOWNSTREAM, KVER(4, 14, 0));
+    return do_forward6(skb, RAWIP, DOWNSTREAM, KVER_4_14);
 }
 
 DEFINE_BPF_PROG_KVER("schedcls/tether_upstream6_rawip$4_14", TETHERING_UID, TETHERING_GID,
-                     sched_cls_tether_upstream6_rawip_4_14, KVER(4, 14, 0))
+                     sched_cls_tether_upstream6_rawip_4_14, KVER_4_14)
 (struct __sk_buff* skb) {
-    return do_forward6(skb, RAWIP, UPSTREAM, KVER(4, 14, 0));
+    return do_forward6(skb, RAWIP, UPSTREAM, KVER_4_14);
 }
 
 // and define no-op stubs for pre-4.14 kernels.
 DEFINE_BPF_PROG_KVER_RANGE("schedcls/tether_downstream6_rawip$stub", TETHERING_UID, TETHERING_GID,
-                           sched_cls_tether_downstream6_rawip_stub, KVER_NONE, KVER(4, 14, 0))
+                           sched_cls_tether_downstream6_rawip_stub, KVER_NONE, KVER_4_14)
 (struct __sk_buff* skb) {
     return TC_ACT_PIPE;
 }
 
 DEFINE_BPF_PROG_KVER_RANGE("schedcls/tether_upstream6_rawip$stub", TETHERING_UID, TETHERING_GID,
-                           sched_cls_tether_upstream6_rawip_stub, KVER_NONE, KVER(4, 14, 0))
+                           sched_cls_tether_upstream6_rawip_stub, KVER_NONE, KVER_4_14)
 (struct __sk_buff* skb) {
     return TC_ACT_PIPE;
 }
@@ -356,9 +361,10 @@
 
 static inline __always_inline int do_forward4_bottom(struct __sk_buff* skb,
         const int l2_header_size, void* data, const void* data_end,
-        struct ethhdr* eth, struct iphdr* ip, const bool is_ethernet,
-        const bool downstream, const bool updatetime, const bool is_tcp,
-        const unsigned kver) {
+        struct ethhdr* eth, struct iphdr* ip, const struct rawip_bool rawip,
+        const struct stream_bool stream, const struct updatetime_bool updatetime,
+        const bool is_tcp, const struct kver_uint kver) {
+    const bool is_ethernet = !rawip.rawip;
     struct tcphdr* tcph = is_tcp ? (void*)(ip + 1) : NULL;
     struct udphdr* udph = is_tcp ? NULL : (void*)(ip + 1);
 
@@ -416,13 +422,13 @@
     };
     if (is_ethernet) __builtin_memcpy(k.dstMac, eth->h_dest, ETH_ALEN);
 
-    Tether4Value* v = downstream ? bpf_tether_downstream4_map_lookup_elem(&k)
-                                 : bpf_tether_upstream4_map_lookup_elem(&k);
+    Tether4Value* v = stream.down ? bpf_tether_downstream4_map_lookup_elem(&k)
+                                  : bpf_tether_upstream4_map_lookup_elem(&k);
 
     // If we don't find any offload information then simply let the core stack handle it...
     if (!v) return TC_ACT_PIPE;
 
-    uint32_t stat_and_limit_k = downstream ? skb->ifindex : v->oif;
+    uint32_t stat_and_limit_k = stream.down ? skb->ifindex : v->oif;
 
     TetherStatsValue* stat_v = bpf_tether_stats_map_lookup_elem(&stat_and_limit_k);
 
@@ -467,7 +473,7 @@
         // We do this even if TX interface is RAWIP and thus does not need an ethernet header,
         // because this is easier and the kernel will strip extraneous ethernet header.
         if (bpf_skb_change_head(skb, sizeof(struct ethhdr), /*flags*/ 0)) {
-            __sync_fetch_and_add(downstream ? &stat_v->rxErrors : &stat_v->txErrors, 1);
+            __sync_fetch_and_add(stream.down ? &stat_v->rxErrors : &stat_v->txErrors, 1);
             TC_PUNT(CHANGE_HEAD_FAILED);
         }
 
@@ -481,7 +487,7 @@
 
         // I do not believe this can ever happen, but keep the verifier happy...
         if (data + sizeof(struct ethhdr) + sizeof(*ip) + (is_tcp ? sizeof(*tcph) : sizeof(*udph)) > data_end) {
-            __sync_fetch_and_add(downstream ? &stat_v->rxErrors : &stat_v->txErrors, 1);
+            __sync_fetch_and_add(stream.down ? &stat_v->rxErrors : &stat_v->txErrors, 1);
             TC_DROP(TOO_SHORT);
         }
     };
@@ -533,10 +539,10 @@
 
     // This requires the bpf_ktime_get_boot_ns() helper which was added in 5.8,
     // and backported to all Android Common Kernel 4.14+ trees.
-    if (updatetime) v->last_used = bpf_ktime_get_boot_ns();
+    if (updatetime.updatetime) v->last_used = bpf_ktime_get_boot_ns();
 
-    __sync_fetch_and_add(downstream ? &stat_v->rxPackets : &stat_v->txPackets, packets);
-    __sync_fetch_and_add(downstream ? &stat_v->rxBytes : &stat_v->txBytes, L3_bytes);
+    __sync_fetch_and_add(stream.down ? &stat_v->rxPackets : &stat_v->txPackets, packets);
+    __sync_fetch_and_add(stream.down ? &stat_v->rxBytes : &stat_v->txBytes, L3_bytes);
 
     // Redirect to forwarded interface.
     //
@@ -547,8 +553,13 @@
     return bpf_redirect(v->oif, 0 /* this is effectively BPF_F_EGRESS */);
 }
 
-static inline __always_inline int do_forward4(struct __sk_buff* skb, const bool is_ethernet,
-        const bool downstream, const bool updatetime, const unsigned kver) {
+static inline __always_inline int do_forward4(struct __sk_buff* skb,
+                                              const struct rawip_bool rawip,
+                                              const struct stream_bool stream,
+                                              const struct updatetime_bool updatetime,
+                                              const struct kver_uint kver) {
+    const bool is_ethernet = !rawip.rawip;
+
     // Require ethernet dst mac address to be our unicast address.
     if (is_ethernet && (skb->pkt_type != PACKET_HOST)) return TC_ACT_PIPE;
 
@@ -606,16 +617,16 @@
     // in such a situation we can only support TCP.  This also has the added nice benefit of
     // using a separate error counter, and thus making it obvious which version of the program
     // is loaded.
-    if (!updatetime && ip->protocol != IPPROTO_TCP) TC_PUNT(NON_TCP);
+    if (!updatetime.updatetime && ip->protocol != IPPROTO_TCP) TC_PUNT(NON_TCP);
 
     // We do not support offloading anything besides IPv4 TCP and UDP, due to need for NAT,
     // but no need to check this if !updatetime due to check immediately above.
-    if (updatetime && (ip->protocol != IPPROTO_TCP) && (ip->protocol != IPPROTO_UDP))
+    if (updatetime.updatetime && (ip->protocol != IPPROTO_TCP) && (ip->protocol != IPPROTO_UDP))
         TC_PUNT(NON_TCP_UDP);
 
     // We want to make sure that the compiler will, in the !updatetime case, entirely optimize
     // out all the non-tcp logic.  Also note that at this point is_udp === !is_tcp.
-    const bool is_tcp = !updatetime || (ip->protocol == IPPROTO_TCP);
+    const bool is_tcp = !updatetime.updatetime || (ip->protocol == IPPROTO_TCP);
 
     // This is a bit of a hack to make things easier on the bpf verifier.
     // (In particular I believe the Linux 4.14 kernel's verifier can get confused later on about
@@ -636,37 +647,37 @@
     // if the underlying requisite kernel support (bpf_ktime_get_boot_ns) was backported.
     if (is_tcp) {
       return do_forward4_bottom(skb, l2_header_size, data, data_end, eth, ip,
-                                is_ethernet, downstream, updatetime, /* is_tcp */ true, kver);
+                                rawip, stream, updatetime, /* is_tcp */ true, kver);
     } else {
       return do_forward4_bottom(skb, l2_header_size, data, data_end, eth, ip,
-                                is_ethernet, downstream, updatetime, /* is_tcp */ false, kver);
+                                rawip, stream, updatetime, /* is_tcp */ false, kver);
     }
 }
 
 // Full featured (required) implementations for 5.8+ kernels (these are S+ by definition)
 
 DEFINE_BPF_PROG_KVER("schedcls/tether_downstream4_rawip$5_8", TETHERING_UID, TETHERING_GID,
-                     sched_cls_tether_downstream4_rawip_5_8, KVER(5, 8, 0))
+                     sched_cls_tether_downstream4_rawip_5_8, KVER_5_8)
 (struct __sk_buff* skb) {
-    return do_forward4(skb, RAWIP, DOWNSTREAM, UPDATETIME, KVER(5, 8, 0));
+    return do_forward4(skb, RAWIP, DOWNSTREAM, UPDATETIME, KVER_5_8);
 }
 
 DEFINE_BPF_PROG_KVER("schedcls/tether_upstream4_rawip$5_8", TETHERING_UID, TETHERING_GID,
-                     sched_cls_tether_upstream4_rawip_5_8, KVER(5, 8, 0))
+                     sched_cls_tether_upstream4_rawip_5_8, KVER_5_8)
 (struct __sk_buff* skb) {
-    return do_forward4(skb, RAWIP, UPSTREAM, UPDATETIME, KVER(5, 8, 0));
+    return do_forward4(skb, RAWIP, UPSTREAM, UPDATETIME, KVER_5_8);
 }
 
 DEFINE_BPF_PROG_KVER("schedcls/tether_downstream4_ether$5_8", TETHERING_UID, TETHERING_GID,
-                     sched_cls_tether_downstream4_ether_5_8, KVER(5, 8, 0))
+                     sched_cls_tether_downstream4_ether_5_8, KVER_5_8)
 (struct __sk_buff* skb) {
-    return do_forward4(skb, ETHER, DOWNSTREAM, UPDATETIME, KVER(5, 8, 0));
+    return do_forward4(skb, ETHER, DOWNSTREAM, UPDATETIME, KVER_5_8);
 }
 
 DEFINE_BPF_PROG_KVER("schedcls/tether_upstream4_ether$5_8", TETHERING_UID, TETHERING_GID,
-                     sched_cls_tether_upstream4_ether_5_8, KVER(5, 8, 0))
+                     sched_cls_tether_upstream4_ether_5_8, KVER_5_8)
 (struct __sk_buff* skb) {
-    return do_forward4(skb, ETHER, UPSTREAM, UPDATETIME, KVER(5, 8, 0));
+    return do_forward4(skb, ETHER, UPSTREAM, UPDATETIME, KVER_5_8);
 }
 
 // Full featured (optional) implementations for 4.14-S, 4.19-S & 5.4-S kernels
@@ -675,33 +686,33 @@
 DEFINE_OPTIONAL_BPF_PROG_KVER_RANGE("schedcls/tether_downstream4_rawip$opt",
                                     TETHERING_UID, TETHERING_GID,
                                     sched_cls_tether_downstream4_rawip_opt,
-                                    KVER(4, 14, 0), KVER(5, 8, 0))
+                                    KVER_4_14, KVER_5_8)
 (struct __sk_buff* skb) {
-    return do_forward4(skb, RAWIP, DOWNSTREAM, UPDATETIME, KVER(4, 14, 0));
+    return do_forward4(skb, RAWIP, DOWNSTREAM, UPDATETIME, KVER_4_14);
 }
 
 DEFINE_OPTIONAL_BPF_PROG_KVER_RANGE("schedcls/tether_upstream4_rawip$opt",
                                     TETHERING_UID, TETHERING_GID,
                                     sched_cls_tether_upstream4_rawip_opt,
-                                    KVER(4, 14, 0), KVER(5, 8, 0))
+                                    KVER_4_14, KVER_5_8)
 (struct __sk_buff* skb) {
-    return do_forward4(skb, RAWIP, UPSTREAM, UPDATETIME, KVER(4, 14, 0));
+    return do_forward4(skb, RAWIP, UPSTREAM, UPDATETIME, KVER_4_14);
 }
 
 DEFINE_OPTIONAL_BPF_PROG_KVER_RANGE("schedcls/tether_downstream4_ether$opt",
                                     TETHERING_UID, TETHERING_GID,
                                     sched_cls_tether_downstream4_ether_opt,
-                                    KVER(4, 14, 0), KVER(5, 8, 0))
+                                    KVER_4_14, KVER_5_8)
 (struct __sk_buff* skb) {
-    return do_forward4(skb, ETHER, DOWNSTREAM, UPDATETIME, KVER(4, 14, 0));
+    return do_forward4(skb, ETHER, DOWNSTREAM, UPDATETIME, KVER_4_14);
 }
 
 DEFINE_OPTIONAL_BPF_PROG_KVER_RANGE("schedcls/tether_upstream4_ether$opt",
                                     TETHERING_UID, TETHERING_GID,
                                     sched_cls_tether_upstream4_ether_opt,
-                                    KVER(4, 14, 0), KVER(5, 8, 0))
+                                    KVER_4_14, KVER_5_8)
 (struct __sk_buff* skb) {
-    return do_forward4(skb, ETHER, UPSTREAM, UPDATETIME, KVER(4, 14, 0));
+    return do_forward4(skb, ETHER, UPSTREAM, UPDATETIME, KVER_4_14);
 }
 
 // Partial (TCP-only: will not update 'last_used' field) implementations for 4.14+ kernels.
@@ -719,15 +730,15 @@
 // RAWIP: Required for 5.4-R kernels -- which always support bpf_skb_change_head().
 
 DEFINE_BPF_PROG_KVER_RANGE("schedcls/tether_downstream4_rawip$5_4", TETHERING_UID, TETHERING_GID,
-                           sched_cls_tether_downstream4_rawip_5_4, KVER(5, 4, 0), KVER(5, 8, 0))
+                           sched_cls_tether_downstream4_rawip_5_4, KVER_5_4, KVER_5_8)
 (struct __sk_buff* skb) {
-    return do_forward4(skb, RAWIP, DOWNSTREAM, NO_UPDATETIME, KVER(5, 4, 0));
+    return do_forward4(skb, RAWIP, DOWNSTREAM, NO_UPDATETIME, KVER_5_4);
 }
 
 DEFINE_BPF_PROG_KVER_RANGE("schedcls/tether_upstream4_rawip$5_4", TETHERING_UID, TETHERING_GID,
-                           sched_cls_tether_upstream4_rawip_5_4, KVER(5, 4, 0), KVER(5, 8, 0))
+                           sched_cls_tether_upstream4_rawip_5_4, KVER_5_4, KVER_5_8)
 (struct __sk_buff* skb) {
-    return do_forward4(skb, RAWIP, UPSTREAM, NO_UPDATETIME, KVER(5, 4, 0));
+    return do_forward4(skb, RAWIP, UPSTREAM, NO_UPDATETIME, KVER_5_4);
 }
 
 // RAWIP: Optional for 4.14/4.19 (R) kernels -- which support bpf_skb_change_head().
@@ -736,31 +747,31 @@
 DEFINE_OPTIONAL_BPF_PROG_KVER_RANGE("schedcls/tether_downstream4_rawip$4_14",
                                     TETHERING_UID, TETHERING_GID,
                                     sched_cls_tether_downstream4_rawip_4_14,
-                                    KVER(4, 14, 0), KVER(5, 4, 0))
+                                    KVER_4_14, KVER_5_4)
 (struct __sk_buff* skb) {
-    return do_forward4(skb, RAWIP, DOWNSTREAM, NO_UPDATETIME, KVER(4, 14, 0));
+    return do_forward4(skb, RAWIP, DOWNSTREAM, NO_UPDATETIME, KVER_4_14);
 }
 
 DEFINE_OPTIONAL_BPF_PROG_KVER_RANGE("schedcls/tether_upstream4_rawip$4_14",
                                     TETHERING_UID, TETHERING_GID,
                                     sched_cls_tether_upstream4_rawip_4_14,
-                                    KVER(4, 14, 0), KVER(5, 4, 0))
+                                    KVER_4_14, KVER_5_4)
 (struct __sk_buff* skb) {
-    return do_forward4(skb, RAWIP, UPSTREAM, NO_UPDATETIME, KVER(4, 14, 0));
+    return do_forward4(skb, RAWIP, UPSTREAM, NO_UPDATETIME, KVER_4_14);
 }
 
 // ETHER: Required for 4.14-Q/R, 4.19-Q/R & 5.4-R kernels.
 
 DEFINE_BPF_PROG_KVER_RANGE("schedcls/tether_downstream4_ether$4_14", TETHERING_UID, TETHERING_GID,
-                           sched_cls_tether_downstream4_ether_4_14, KVER(4, 14, 0), KVER(5, 8, 0))
+                           sched_cls_tether_downstream4_ether_4_14, KVER_4_14, KVER_5_8)
 (struct __sk_buff* skb) {
-    return do_forward4(skb, ETHER, DOWNSTREAM, NO_UPDATETIME, KVER(4, 14, 0));
+    return do_forward4(skb, ETHER, DOWNSTREAM, NO_UPDATETIME, KVER_4_14);
 }
 
 DEFINE_BPF_PROG_KVER_RANGE("schedcls/tether_upstream4_ether$4_14", TETHERING_UID, TETHERING_GID,
-                           sched_cls_tether_upstream4_ether_4_14, KVER(4, 14, 0), KVER(5, 8, 0))
+                           sched_cls_tether_upstream4_ether_4_14, KVER_4_14, KVER_5_8)
 (struct __sk_buff* skb) {
-    return do_forward4(skb, ETHER, UPSTREAM, NO_UPDATETIME, KVER(4, 14, 0));
+    return do_forward4(skb, ETHER, UPSTREAM, NO_UPDATETIME, KVER_4_14);
 }
 
 // Placeholder (no-op) implementations for older Q kernels
@@ -768,13 +779,13 @@
 // RAWIP: 4.9-P/Q, 4.14-P/Q & 4.19-Q kernels -- without bpf_skb_change_head() for tc programs
 
 DEFINE_BPF_PROG_KVER_RANGE("schedcls/tether_downstream4_rawip$stub", TETHERING_UID, TETHERING_GID,
-                           sched_cls_tether_downstream4_rawip_stub, KVER_NONE, KVER(5, 4, 0))
+                           sched_cls_tether_downstream4_rawip_stub, KVER_NONE, KVER_5_4)
 (struct __sk_buff* skb) {
     return TC_ACT_PIPE;
 }
 
 DEFINE_BPF_PROG_KVER_RANGE("schedcls/tether_upstream4_rawip$stub", TETHERING_UID, TETHERING_GID,
-                           sched_cls_tether_upstream4_rawip_stub, KVER_NONE, KVER(5, 4, 0))
+                           sched_cls_tether_upstream4_rawip_stub, KVER_NONE, KVER_5_4)
 (struct __sk_buff* skb) {
     return TC_ACT_PIPE;
 }
@@ -782,13 +793,13 @@
 // ETHER: 4.9-P/Q kernel
 
 DEFINE_BPF_PROG_KVER_RANGE("schedcls/tether_downstream4_ether$stub", TETHERING_UID, TETHERING_GID,
-                           sched_cls_tether_downstream4_ether_stub, KVER_NONE, KVER(4, 14, 0))
+                           sched_cls_tether_downstream4_ether_stub, KVER_NONE, KVER_4_14)
 (struct __sk_buff* skb) {
     return TC_ACT_PIPE;
 }
 
 DEFINE_BPF_PROG_KVER_RANGE("schedcls/tether_upstream4_ether$stub", TETHERING_UID, TETHERING_GID,
-                           sched_cls_tether_upstream4_ether_stub, KVER_NONE, KVER(4, 14, 0))
+                           sched_cls_tether_upstream4_ether_stub, KVER_NONE, KVER_4_14)
 (struct __sk_buff* skb) {
     return TC_ACT_PIPE;
 }
@@ -797,17 +808,18 @@
 
 DEFINE_BPF_MAP_GRW(tether_dev_map, DEVMAP_HASH, uint32_t, uint32_t, 64, TETHERING_GID)
 
-static inline __always_inline int do_xdp_forward6(struct xdp_md *ctx, const bool is_ethernet,
-        const bool downstream) {
+static inline __always_inline int do_xdp_forward6(struct xdp_md *ctx, const struct rawip_bool rawip,
+        const struct stream_bool stream) {
     return XDP_PASS;
 }
 
-static inline __always_inline int do_xdp_forward4(struct xdp_md *ctx, const bool is_ethernet,
-        const bool downstream) {
+static inline __always_inline int do_xdp_forward4(struct xdp_md *ctx, const struct rawip_bool rawip,
+        const struct stream_bool stream) {
     return XDP_PASS;
 }
 
-static inline __always_inline int do_xdp_forward_ether(struct xdp_md *ctx, const bool downstream) {
+static inline __always_inline int do_xdp_forward_ether(struct xdp_md *ctx,
+                                                       const struct stream_bool stream) {
     const void* data = (void*)(long)ctx->data;
     const void* data_end = (void*)(long)ctx->data_end;
     const struct ethhdr* eth = data;
@@ -816,15 +828,16 @@
     if ((void*)(eth + 1) > data_end) return XDP_PASS;
 
     if (eth->h_proto == htons(ETH_P_IPV6))
-        return do_xdp_forward6(ctx, ETHER, downstream);
+        return do_xdp_forward6(ctx, ETHER, stream);
     if (eth->h_proto == htons(ETH_P_IP))
-        return do_xdp_forward4(ctx, ETHER, downstream);
+        return do_xdp_forward4(ctx, ETHER, stream);
 
     // Anything else we don't know how to handle...
     return XDP_PASS;
 }
 
-static inline __always_inline int do_xdp_forward_rawip(struct xdp_md *ctx, const bool downstream) {
+static inline __always_inline int do_xdp_forward_rawip(struct xdp_md *ctx,
+                                                       const struct stream_bool stream) {
     const void* data = (void*)(long)ctx->data;
     const void* data_end = (void*)(long)ctx->data_end;
 
@@ -832,15 +845,15 @@
     if (data_end - data < 1) return XDP_PASS;
     const uint8_t v = (*(uint8_t*)data) >> 4;
 
-    if (v == 6) return do_xdp_forward6(ctx, RAWIP, downstream);
-    if (v == 4) return do_xdp_forward4(ctx, RAWIP, downstream);
+    if (v == 6) return do_xdp_forward6(ctx, RAWIP, stream);
+    if (v == 4) return do_xdp_forward4(ctx, RAWIP, stream);
 
     // Anything else we don't know how to handle...
     return XDP_PASS;
 }
 
 #define DEFINE_XDP_PROG(str, func) \
-    DEFINE_BPF_PROG_KVER(str, TETHERING_UID, TETHERING_GID, func, KVER(5, 9, 0))(struct xdp_md *ctx)
+    DEFINE_BPF_PROG_KVER(str, TETHERING_UID, TETHERING_GID, func, KVER_5_9)(struct xdp_md *ctx)
 
 DEFINE_XDP_PROG("xdp/tether_downstream_ether",
                  xdp_tether_downstream_ether) {
diff --git a/bpf_progs/test.c b/bpf_progs/test.c
index 68469c8..70b08b7 100644
--- a/bpf_progs/test.c
+++ b/bpf_progs/test.c
@@ -49,7 +49,7 @@
 DEFINE_BPF_MAP_GRW(bitmap, ARRAY, int, uint64_t, 2, TETHERING_GID)
 
 DEFINE_BPF_PROG_KVER("xdp/drop_ipv4_udp_ether", TETHERING_UID, TETHERING_GID,
-                      xdp_test, KVER(5, 9, 0))
+                      xdp_test, KVER_5_9)
 (struct xdp_md *ctx) {
     void *data = (void *)(long)ctx->data;
     void *data_end = (void *)(long)ctx->data_end;
diff --git a/common/Android.bp b/common/Android.bp
index ff4de11..1d73a46 100644
--- a/common/Android.bp
+++ b/common/Android.bp
@@ -19,6 +19,13 @@
     default_applicable_licenses: ["Android-Apache-2.0"],
 }
 
+build = ["TrunkStable.bp"]
+
+// This is a placeholder comment to avoid merge conflicts
+// as the above target may not exist
+// depending on the branch
+
+// The library requires the final artifact to contain net-utils-device-common-struct.
 java_library {
     name: "connectivity-net-module-utils-bpf",
     srcs: [
@@ -34,8 +41,9 @@
     libs: [
         "androidx.annotation_annotation",
         "framework-connectivity.stubs.module_lib",
-    ],
-    static_libs: [
+        // 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",
     ],
     apex_available: [
diff --git a/common/TrunkStable.bp b/common/TrunkStable.bp
new file mode 100644
index 0000000..56938fc
--- /dev/null
+++ b/common/TrunkStable.bp
@@ -0,0 +1,26 @@
+//
+// 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.
+//
+
+aconfig_declarations {
+    name: "com.android.net.flags-aconfig",
+    package: "com.android.net.flags",
+    srcs: ["flags.aconfig"],
+}
+
+java_aconfig_library {
+    name: "connectivity_flags_aconfig_lib",
+    aconfig_declarations: "com.android.net.flags-aconfig",
+}
diff --git a/common/flags.aconfig b/common/flags.aconfig
new file mode 100644
index 0000000..0c46b48
--- /dev/null
+++ b/common/flags.aconfig
@@ -0,0 +1,36 @@
+package: "com.android.net.flags"
+
+flag {
+  name: "track_multiple_network_activities"
+  namespace: "android_core_networking"
+  description: "NetworkActivityTracker tracks multiple networks including non default networks"
+  bug: "267870186"
+}
+
+flag {
+  name: "forbidden_capability"
+  namespace: "android_core_networking"
+  description: "This flag controls the forbidden capability API"
+  bug: "302997505"
+}
+
+flag {
+  name: "nsd_expired_services_removal"
+  namespace: "android_core_networking"
+  description: "Remove expired services from MdnsServiceCache"
+  bug: "304649384"
+}
+
+flag {
+  name: "set_data_saver_via_cm"
+  namespace: "android_core_networking"
+  description: "Set data saver through ConnectivityManager API"
+  bug: "297836825"
+}
+
+flag {
+  name: "support_is_uid_networking_blocked"
+  namespace: "android_core_networking"
+  description: "This flag controls whether isUidNetworkingBlocked is supported"
+  bug: "297836825"
+}
diff --git a/common/src/com/android/net/module/util/bpf/IngressDiscardKey.java b/common/src/com/android/net/module/util/bpf/IngressDiscardKey.java
new file mode 100644
index 0000000..9fefb52
--- /dev/null
+++ b/common/src/com/android/net/module/util/bpf/IngressDiscardKey.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.net.module.util.bpf;
+
+import com.android.net.module.util.InetAddressUtils;
+import com.android.net.module.util.Struct;
+
+import java.net.Inet4Address;
+import java.net.Inet6Address;
+import java.net.InetAddress;
+
+/** Key type for ingress discard map */
+public class IngressDiscardKey extends Struct {
+    // The destination ip of the incoming packet. IPv4 uses IPv4-mapped IPv6 address.
+    @Field(order = 0, type = Type.Ipv6Address)
+    public final Inet6Address dstAddr;
+
+    public IngressDiscardKey(final Inet6Address dstAddr) {
+        this.dstAddr = dstAddr;
+    }
+
+    private static Inet6Address getInet6Address(final InetAddress addr) {
+        return (addr instanceof Inet4Address)
+                ? InetAddressUtils.v4MappedV6Address((Inet4Address) addr)
+                : (Inet6Address) addr;
+    }
+
+    public IngressDiscardKey(final InetAddress dstAddr) {
+        this(getInet6Address(dstAddr));
+    }
+}
diff --git a/common/src/com/android/net/module/util/bpf/IngressDiscardValue.java b/common/src/com/android/net/module/util/bpf/IngressDiscardValue.java
new file mode 100644
index 0000000..7df3620
--- /dev/null
+++ b/common/src/com/android/net/module/util/bpf/IngressDiscardValue.java
@@ -0,0 +1,34 @@
+/*
+ * 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.net.module.util.bpf;
+
+import com.android.net.module.util.Struct;
+
+/** Value type for ingress discard map */
+public class IngressDiscardValue extends Struct {
+    // Allowed interface indexes.
+    // Use the same value for iif1 and iif2 if there is only a single allowed interface index.
+    @Field(order = 0, type = Type.S32)
+    public final int iif1;
+    @Field(order = 1, type = Type.S32)
+    public final int iif2;
+
+    public IngressDiscardValue(final int iif1, final int iif2) {
+        this.iif1 = iif1;
+        this.iif2 = iif2;
+    }
+}
diff --git a/framework-t/Android.bp b/framework-t/Android.bp
index ba0d4d9..d177ea9 100644
--- a/framework-t/Android.bp
+++ b/framework-t/Android.bp
@@ -51,7 +51,7 @@
         ":framework-connectivity-tiramisu-updatable-sources",
         ":framework-nearby-java-sources",
         ":framework-thread-sources",
-    ] + framework_remoteauth_srcs,
+    ],
     libs: [
         "unsupportedappusage",
         "app-compat-annotations",
@@ -126,7 +126,6 @@
         "enable-framework-connectivity-t-targets",
         "FlaggedApiDefaults",
     ],
-    api_srcs: framework_remoteauth_api_srcs,
     // Do not add static_libs to this library: put them in framework-connectivity instead.
     // The jarjar rules are only so that references to jarjared utils in
     // framework-connectivity-pre-jarjar match at runtime.
@@ -143,10 +142,8 @@
         "android.net",
         "android.net.nsd",
         "android.nearby",
-        "android.remoteauth",
         "com.android.connectivity",
         "com.android.nearby",
-        "com.android.remoteauth",
     ],
 
     hidden_api: {
diff --git a/framework-t/api/OWNERS b/framework-t/api/OWNERS
index af583c3..607f85a 100644
--- a/framework-t/api/OWNERS
+++ b/framework-t/api/OWNERS
@@ -1,2 +1,2 @@
-file:platform/packages/modules/Connectivity:master:/nearby/OWNERS
-file:platform/packages/modules/Connectivity:master:/remoteauth/OWNERS
+file:platform/packages/modules/Connectivity:main:/nearby/OWNERS
+file:platform/packages/modules/Connectivity:main:/remoteauth/OWNERS
diff --git a/framework-t/api/system-current.txt b/framework-t/api/system-current.txt
index ea465aa..23510e1 100644
--- a/framework-t/api/system-current.txt
+++ b/framework-t/api/system-current.txt
@@ -59,12 +59,30 @@
   }
 
   public class NearbyManager {
+    method public void queryOffloadCapability(@NonNull java.util.concurrent.Executor, @NonNull java.util.function.Consumer<android.nearby.OffloadCapability>);
     method @RequiresPermission(allOf={android.Manifest.permission.BLUETOOTH_ADVERTISE, android.Manifest.permission.BLUETOOTH_PRIVILEGED}) public void startBroadcast(@NonNull android.nearby.BroadcastRequest, @NonNull java.util.concurrent.Executor, @NonNull android.nearby.BroadcastCallback);
     method @RequiresPermission(allOf={android.Manifest.permission.BLUETOOTH_SCAN, android.Manifest.permission.BLUETOOTH_PRIVILEGED}) public int startScan(@NonNull android.nearby.ScanRequest, @NonNull java.util.concurrent.Executor, @NonNull android.nearby.ScanCallback);
     method @RequiresPermission(allOf={android.Manifest.permission.BLUETOOTH_ADVERTISE, android.Manifest.permission.BLUETOOTH_PRIVILEGED}) public void stopBroadcast(@NonNull android.nearby.BroadcastCallback);
     method @RequiresPermission(allOf={android.Manifest.permission.BLUETOOTH_SCAN, android.Manifest.permission.BLUETOOTH_PRIVILEGED}) public void stopScan(@NonNull android.nearby.ScanCallback);
   }
 
+  public final class OffloadCapability implements android.os.Parcelable {
+    method public int describeContents();
+    method public long getVersion();
+    method public boolean isFastPairSupported();
+    method public boolean isNearbyShareSupported();
+    method public void writeToParcel(@NonNull android.os.Parcel, int);
+    field @NonNull public static final android.os.Parcelable.Creator<android.nearby.OffloadCapability> CREATOR;
+  }
+
+  public static final class OffloadCapability.Builder {
+    ctor public OffloadCapability.Builder();
+    method @NonNull public android.nearby.OffloadCapability build();
+    method @NonNull public android.nearby.OffloadCapability.Builder setFastPairSupported(boolean);
+    method @NonNull public android.nearby.OffloadCapability.Builder setNearbyShareSupported(boolean);
+    method @NonNull public android.nearby.OffloadCapability.Builder setVersion(long);
+  }
+
   public final class PresenceBroadcastRequest extends android.nearby.BroadcastRequest implements android.os.Parcelable {
     method public int describeContents();
     method @NonNull public java.util.List<java.lang.Integer> getActions();
@@ -175,8 +193,14 @@
 
   public interface ScanCallback {
     method public void onDiscovered(@NonNull android.nearby.NearbyDevice);
+    method public default void onError(int);
     method public void onLost(@NonNull android.nearby.NearbyDevice);
     method public void onUpdated(@NonNull android.nearby.NearbyDevice);
+    field public static final int ERROR_INVALID_ARGUMENT = 2; // 0x2
+    field public static final int ERROR_PERMISSION_DENIED = 3; // 0x3
+    field public static final int ERROR_RESOURCE_EXHAUSTED = 4; // 0x4
+    field public static final int ERROR_UNKNOWN = 0; // 0x0
+    field public static final int ERROR_UNSUPPORTED = 1; // 0x1
   }
 
   public abstract class ScanFilter {
@@ -191,6 +215,7 @@
     method public int getScanType();
     method @NonNull public android.os.WorkSource getWorkSource();
     method public boolean isBleEnabled();
+    method public boolean isOffloadOnly();
     method public static boolean isValidScanMode(int);
     method public static boolean isValidScanType(int);
     method @NonNull public static String scanModeToString(int);
@@ -209,6 +234,7 @@
     method @NonNull public android.nearby.ScanRequest.Builder addScanFilter(@NonNull android.nearby.ScanFilter);
     method @NonNull public android.nearby.ScanRequest build();
     method @NonNull public android.nearby.ScanRequest.Builder setBleEnabled(boolean);
+    method @NonNull public android.nearby.ScanRequest.Builder setOffloadOnly(boolean);
     method @NonNull public android.nearby.ScanRequest.Builder setScanMode(int);
     method @NonNull public android.nearby.ScanRequest.Builder setScanType(int);
     method @NonNull @RequiresPermission(android.Manifest.permission.UPDATE_DEVICE_STATS) public android.nearby.ScanRequest.Builder setWorkSource(@Nullable android.os.WorkSource);
@@ -279,6 +305,7 @@
     ctor public NetworkStats(long, int);
     method @NonNull public android.net.NetworkStats add(@NonNull android.net.NetworkStats);
     method @NonNull public android.net.NetworkStats addEntry(@NonNull android.net.NetworkStats.Entry);
+    method public android.net.NetworkStats clone();
     method public int describeContents();
     method @NonNull public java.util.Iterator<android.net.NetworkStats.Entry> iterator();
     method @NonNull public android.net.NetworkStats subtract(@NonNull android.net.NetworkStats);
@@ -390,12 +417,87 @@
 
 package android.net.thread {
 
-  public class ThreadNetworkController {
+  @FlaggedApi("com.android.net.thread.flags.thread_enabled") public final class ActiveOperationalDataset implements android.os.Parcelable {
+    method @NonNull public static android.net.thread.ActiveOperationalDataset createRandomDataset();
+    method public int describeContents();
+    method @NonNull public static android.net.thread.ActiveOperationalDataset fromThreadTlvs(@NonNull byte[]);
+    method @NonNull public android.net.thread.OperationalDatasetTimestamp getActiveTimestamp();
+    method @IntRange(from=0, to=65535) public int getChannel();
+    method @NonNull @Size(min=1) public android.util.SparseArray<byte[]> getChannelMask();
+    method @IntRange(from=0, to=255) public int getChannelPage();
+    method @NonNull @Size(android.net.thread.ActiveOperationalDataset.LENGTH_EXTENDED_PAN_ID) public byte[] getExtendedPanId();
+    method @NonNull public android.net.IpPrefix getMeshLocalPrefix();
+    method @NonNull @Size(android.net.thread.ActiveOperationalDataset.LENGTH_NETWORK_KEY) public byte[] getNetworkKey();
+    method @NonNull @Size(min=android.net.thread.ActiveOperationalDataset.LENGTH_MIN_NETWORK_NAME_BYTES, max=android.net.thread.ActiveOperationalDataset.LENGTH_MAX_NETWORK_NAME_BYTES) public String getNetworkName();
+    method @IntRange(from=0, to=65534) public int getPanId();
+    method @NonNull @Size(android.net.thread.ActiveOperationalDataset.LENGTH_PSKC) public byte[] getPskc();
+    method @NonNull public android.net.thread.ActiveOperationalDataset.SecurityPolicy getSecurityPolicy();
+    method @NonNull public byte[] toThreadTlvs();
+    method public void writeToParcel(@NonNull android.os.Parcel, int);
+    field public static final int CHANNEL_MAX_24_GHZ = 26; // 0x1a
+    field public static final int CHANNEL_MIN_24_GHZ = 11; // 0xb
+    field public static final int CHANNEL_PAGE_24_GHZ = 0; // 0x0
+    field @NonNull public static final android.os.Parcelable.Creator<android.net.thread.ActiveOperationalDataset> CREATOR;
+    field public static final int LENGTH_EXTENDED_PAN_ID = 8; // 0x8
+    field public static final int LENGTH_MAX_DATASET_TLVS = 254; // 0xfe
+    field public static final int LENGTH_MAX_NETWORK_NAME_BYTES = 16; // 0x10
+    field public static final int LENGTH_MESH_LOCAL_PREFIX_BITS = 64; // 0x40
+    field public static final int LENGTH_MIN_NETWORK_NAME_BYTES = 1; // 0x1
+    field public static final int LENGTH_NETWORK_KEY = 16; // 0x10
+    field public static final int LENGTH_PSKC = 16; // 0x10
+  }
+
+  public static final class ActiveOperationalDataset.Builder {
+    ctor public ActiveOperationalDataset.Builder(@NonNull android.net.thread.ActiveOperationalDataset);
+    ctor public ActiveOperationalDataset.Builder();
+    method @NonNull public android.net.thread.ActiveOperationalDataset build();
+    method @NonNull public android.net.thread.ActiveOperationalDataset.Builder setActiveTimestamp(@NonNull android.net.thread.OperationalDatasetTimestamp);
+    method @NonNull public android.net.thread.ActiveOperationalDataset.Builder setChannel(@IntRange(from=0, to=255) int, @IntRange(from=0, to=65535) int);
+    method @NonNull public android.net.thread.ActiveOperationalDataset.Builder setChannelMask(@NonNull @Size(min=1) android.util.SparseArray<byte[]>);
+    method @NonNull public android.net.thread.ActiveOperationalDataset.Builder setExtendedPanId(@NonNull @Size(android.net.thread.ActiveOperationalDataset.LENGTH_EXTENDED_PAN_ID) byte[]);
+    method @NonNull public android.net.thread.ActiveOperationalDataset.Builder setMeshLocalPrefix(@NonNull android.net.IpPrefix);
+    method @NonNull public android.net.thread.ActiveOperationalDataset.Builder setNetworkKey(@NonNull @Size(android.net.thread.ActiveOperationalDataset.LENGTH_NETWORK_KEY) byte[]);
+    method @NonNull public android.net.thread.ActiveOperationalDataset.Builder setNetworkName(@NonNull @Size(min=android.net.thread.ActiveOperationalDataset.LENGTH_MIN_NETWORK_NAME_BYTES, max=android.net.thread.ActiveOperationalDataset.LENGTH_MAX_NETWORK_NAME_BYTES) String);
+    method @NonNull public android.net.thread.ActiveOperationalDataset.Builder setPanId(@IntRange(from=0, to=65534) int);
+    method @NonNull public android.net.thread.ActiveOperationalDataset.Builder setPskc(@NonNull @Size(android.net.thread.ActiveOperationalDataset.LENGTH_PSKC) byte[]);
+    method @NonNull public android.net.thread.ActiveOperationalDataset.Builder setSecurityPolicy(@NonNull android.net.thread.ActiveOperationalDataset.SecurityPolicy);
+  }
+
+  public static final class ActiveOperationalDataset.SecurityPolicy {
+    ctor public ActiveOperationalDataset.SecurityPolicy(@IntRange(from=1, to=65535) int, @NonNull @Size(min=android.net.thread.ActiveOperationalDataset.SecurityPolicy.LENGTH_MIN_SECURITY_POLICY_FLAGS) byte[]);
+    method @NonNull @Size(min=android.net.thread.ActiveOperationalDataset.SecurityPolicy.LENGTH_MIN_SECURITY_POLICY_FLAGS) public byte[] getFlags();
+    method @IntRange(from=1, to=65535) public int getRotationTimeHours();
+    field public static final int DEFAULT_ROTATION_TIME_HOURS = 672; // 0x2a0
+    field public static final int LENGTH_MIN_SECURITY_POLICY_FLAGS = 1; // 0x1
+  }
+
+  @FlaggedApi("com.android.net.thread.flags.thread_enabled") public final class OperationalDatasetTimestamp {
+    ctor public OperationalDatasetTimestamp(@IntRange(from=0, to=281474976710655L) long, @IntRange(from=0, to=32767) int, boolean);
+    method @NonNull public static android.net.thread.OperationalDatasetTimestamp fromInstant(@NonNull java.time.Instant);
+    method @IntRange(from=0, to=281474976710655L) public long getSeconds();
+    method @IntRange(from=0, to=32767) public int getTicks();
+    method public boolean isAuthoritativeSource();
+    method @NonNull public java.time.Instant toInstant();
+  }
+
+  @FlaggedApi("com.android.net.thread.flags.thread_enabled") public final class PendingOperationalDataset implements android.os.Parcelable {
+    ctor public PendingOperationalDataset(@NonNull android.net.thread.ActiveOperationalDataset, @NonNull android.net.thread.OperationalDatasetTimestamp, @NonNull java.time.Duration);
+    method public int describeContents();
+    method @NonNull public static android.net.thread.PendingOperationalDataset fromThreadTlvs(@NonNull byte[]);
+    method @NonNull public android.net.thread.ActiveOperationalDataset getActiveOperationalDataset();
+    method @NonNull public java.time.Duration getDelayTimer();
+    method @NonNull public android.net.thread.OperationalDatasetTimestamp getPendingTimestamp();
+    method @NonNull public byte[] toThreadTlvs();
+    method public void writeToParcel(@NonNull android.os.Parcel, int);
+    field @NonNull public static final android.os.Parcelable.Creator<android.net.thread.PendingOperationalDataset> CREATOR;
+  }
+
+  @FlaggedApi("com.android.net.thread.flags.thread_enabled") public final class ThreadNetworkController {
     method public int getThreadVersion();
     field public static final int THREAD_VERSION_1_3 = 4; // 0x4
   }
 
-  public class ThreadNetworkManager {
+  @FlaggedApi("com.android.net.thread.flags.thread_enabled") public final class ThreadNetworkManager {
     method @NonNull public java.util.List<android.net.thread.ThreadNetworkController> getAllThreadNetworkControllers();
   }
 
diff --git a/framework-t/src/android/net/EthernetNetworkSpecifier.java b/framework-t/src/android/net/EthernetNetworkSpecifier.java
index e4d6e24..90c0361 100644
--- a/framework-t/src/android/net/EthernetNetworkSpecifier.java
+++ b/framework-t/src/android/net/EthernetNetworkSpecifier.java
@@ -26,8 +26,6 @@
 
 /**
  * A {@link NetworkSpecifier} used to identify ethernet interfaces.
- *
- * @see EthernetManager
  */
 public final class EthernetNetworkSpecifier extends NetworkSpecifier implements Parcelable {
 
diff --git a/framework-t/udc-extended-api/OWNERS b/framework-t/udc-extended-api/OWNERS
index af583c3..607f85a 100644
--- a/framework-t/udc-extended-api/OWNERS
+++ b/framework-t/udc-extended-api/OWNERS
@@ -1,2 +1,2 @@
-file:platform/packages/modules/Connectivity:master:/nearby/OWNERS
-file:platform/packages/modules/Connectivity:master:/remoteauth/OWNERS
+file:platform/packages/modules/Connectivity:main:/nearby/OWNERS
+file:platform/packages/modules/Connectivity:main:/remoteauth/OWNERS
diff --git a/framework-t/udc-extended-api/system-current.txt b/framework-t/udc-extended-api/system-current.txt
index 1549089..6f0119e 100644
--- a/framework-t/udc-extended-api/system-current.txt
+++ b/framework-t/udc-extended-api/system-current.txt
@@ -305,6 +305,7 @@
     ctor public NetworkStats(long, int);
     method @NonNull public android.net.NetworkStats add(@NonNull android.net.NetworkStats);
     method @NonNull public android.net.NetworkStats addEntry(@NonNull android.net.NetworkStats.Entry);
+    method public android.net.NetworkStats clone();
     method public int describeContents();
     method @NonNull public java.util.Iterator<android.net.NetworkStats.Entry> iterator();
     method @NonNull public android.net.NetworkStats subtract(@NonNull android.net.NetworkStats);
diff --git a/framework/Android.bp b/framework/Android.bp
index 182c558..103083f 100644
--- a/framework/Android.bp
+++ b/framework/Android.bp
@@ -101,7 +101,7 @@
         "framework-connectivity-javastream-protos",
     ],
     impl_only_static_libs: [
-        "net-utils-device-common-struct",
+        "net-utils-device-common-bpf",
     ],
     libs: [
         "androidx.annotation_annotation",
@@ -130,7 +130,7 @@
         // to generate the SDK stubs.
         // 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-struct",
+        "net-utils-device-common-bpf",
     ],
     libs: [
         // This cannot be in the defaults clause above because if it were, it would be used
@@ -292,17 +292,20 @@
 // Library providing limited APIs within the connectivity module, so that R+ components like
 // Tethering have a controlled way to depend on newer components like framework-connectivity that
 // are not loaded on R.
+// Note that this target needs to have access to hidden classes, and as such needs to list
+// the full libraries instead of the .impl lib (which only expose API classes).
 java_library {
     name: "connectivity-internal-api-util",
     sdk_version: "module_current",
     libs: [
         "androidx.annotation_annotation",
-        "framework-connectivity.impl",
+        "framework-connectivity-pre-jarjar",
     ],
     jarjar_rules: ":framework-connectivity-jarjar-rules",
     srcs: [
-        // Files listed here MUST all be annotated with @RequiresApi(Build.VERSION_CODES.TIRAMISU),
-        // so that API checks are enforced for R+ users of this library
+        // Files listed here MUST all be annotated with @RequiresApi(Build.VERSION_CODES.S)
+        // or above as appropriate so that API checks are enforced for R+ users of this library
+        "src/android/net/RoutingCoordinatorManager.java",
         "src/android/net/connectivity/TiramisuConnectivityInternalApiUtil.java",
     ],
     visibility: [
diff --git a/framework/aidl-export/android/net/LocalNetworkConfig.aidl b/framework/aidl-export/android/net/LocalNetworkConfig.aidl
new file mode 100644
index 0000000..e2829a5
--- /dev/null
+++ b/framework/aidl-export/android/net/LocalNetworkConfig.aidl
@@ -0,0 +1,20 @@
+/**
+ *
+ * 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;
+
+@JavaOnlyStableParcelable parcelable LocalNetworkConfig;
diff --git a/framework/api/module-lib-current.txt b/framework/api/module-lib-current.txt
index 193bd92..4d55067 100644
--- a/framework/api/module-lib-current.txt
+++ b/framework/api/module-lib-current.txt
@@ -14,6 +14,7 @@
     method @NonNull public static android.util.Range<java.lang.Integer> getIpSecNetIdRange();
     method @Nullable @RequiresPermission(anyOf={android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK, android.Manifest.permission.NETWORK_STACK, android.Manifest.permission.NETWORK_SETTINGS}) public android.net.LinkProperties getRedactedLinkPropertiesForPackage(@NonNull android.net.LinkProperties, int, @NonNull String);
     method @Nullable @RequiresPermission(anyOf={android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK, android.Manifest.permission.NETWORK_STACK, android.Manifest.permission.NETWORK_SETTINGS}) public android.net.NetworkCapabilities getRedactedNetworkCapabilitiesForPackage(@NonNull android.net.NetworkCapabilities, int, @NonNull String);
+    method @FlaggedApi("com.android.net.flags.support_is_uid_networking_blocked") @RequiresPermission(android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK) public boolean isUidNetworkingBlocked(int, boolean);
     method @RequiresPermission(anyOf={android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK, android.Manifest.permission.NETWORK_SETTINGS}) public void registerDefaultNetworkCallbackForUid(int, @NonNull android.net.ConnectivityManager.NetworkCallback, @NonNull android.os.Handler);
     method @RequiresPermission(anyOf={android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK, android.Manifest.permission.NETWORK_SETTINGS, android.Manifest.permission.NETWORK_SETUP_WIZARD, android.Manifest.permission.CONNECTIVITY_USE_RESTRICTED_NETWORKS}) public void registerSystemDefaultNetworkCallback(@NonNull android.net.ConnectivityManager.NetworkCallback, @NonNull android.os.Handler);
     method @RequiresPermission(anyOf={android.Manifest.permission.NETWORK_SETTINGS, android.Manifest.permission.NETWORK_STACK, android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK}) public void removeUidFromMeteredNetworkAllowList(int);
@@ -24,6 +25,7 @@
     method @RequiresPermission(anyOf={android.Manifest.permission.NETWORK_SETTINGS, android.Manifest.permission.NETWORK_SETUP_WIZARD, android.Manifest.permission.NETWORK_STACK, android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK}) public void setAcceptPartialConnectivity(@NonNull android.net.Network, boolean, boolean);
     method @RequiresPermission(anyOf={android.Manifest.permission.NETWORK_SETTINGS, android.Manifest.permission.NETWORK_SETUP_WIZARD, android.Manifest.permission.NETWORK_STACK, android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK}) public void setAcceptUnvalidated(@NonNull android.net.Network, boolean, boolean);
     method @RequiresPermission(anyOf={android.Manifest.permission.NETWORK_SETTINGS, android.Manifest.permission.NETWORK_SETUP_WIZARD, android.Manifest.permission.NETWORK_STACK, android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK}) public void setAvoidUnvalidated(@NonNull android.net.Network);
+    method @FlaggedApi("com.android.net.flags.set_data_saver_via_cm") @RequiresPermission(anyOf={android.Manifest.permission.NETWORK_SETTINGS, android.Manifest.permission.NETWORK_STACK, android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK}) public void setDataSaverEnabled(boolean);
     method @RequiresPermission(anyOf={android.Manifest.permission.NETWORK_SETTINGS, android.Manifest.permission.NETWORK_STACK, android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK}) public void setFirewallChainEnabled(int, boolean);
     method @RequiresPermission(android.Manifest.permission.NETWORK_STACK) public void setGlobalProxy(@Nullable android.net.ProxyInfo);
     method @RequiresPermission(anyOf={android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK, android.Manifest.permission.NETWORK_STACK, android.Manifest.permission.NETWORK_SETTINGS}) public void setLegacyLockdownVpnEnabled(boolean);
diff --git a/framework/api/system-current.txt b/framework/api/system-current.txt
index 4a2ed8a..e812024 100644
--- a/framework/api/system-current.txt
+++ b/framework/api/system-current.txt
@@ -94,6 +94,7 @@
   }
 
   public final class DscpPolicy implements android.os.Parcelable {
+    method public int describeContents();
     method @Nullable public java.net.InetAddress getDestinationAddress();
     method @Nullable public android.util.Range<java.lang.Integer> getDestinationPortRange();
     method public int getDscpValue();
@@ -101,6 +102,7 @@
     method public int getProtocol();
     method @Nullable public java.net.InetAddress getSourceAddress();
     method public int getSourcePort();
+    method public void writeToParcel(@NonNull android.os.Parcel, int);
     field @NonNull public static final android.os.Parcelable.Creator<android.net.DscpPolicy> CREATOR;
     field public static final int PROTOCOL_ANY = -1; // 0xffffffff
     field public static final int SOURCE_PORT_ANY = -1; // 0xffffffff
diff --git a/framework/cronet_disabled/api/current.txt b/framework/cronet_disabled/api/current.txt
deleted file mode 100644
index 672e3e2..0000000
--- a/framework/cronet_disabled/api/current.txt
+++ /dev/null
@@ -1,527 +0,0 @@
-// Signature format: 2.0
-package android.net {
-
-  public class CaptivePortal implements android.os.Parcelable {
-    method public int describeContents();
-    method public void ignoreNetwork();
-    method public void reportCaptivePortalDismissed();
-    method public void writeToParcel(android.os.Parcel, int);
-    field @NonNull public static final android.os.Parcelable.Creator<android.net.CaptivePortal> CREATOR;
-  }
-
-  public class ConnectivityDiagnosticsManager {
-    method public void registerConnectivityDiagnosticsCallback(@NonNull android.net.NetworkRequest, @NonNull java.util.concurrent.Executor, @NonNull android.net.ConnectivityDiagnosticsManager.ConnectivityDiagnosticsCallback);
-    method public void unregisterConnectivityDiagnosticsCallback(@NonNull android.net.ConnectivityDiagnosticsManager.ConnectivityDiagnosticsCallback);
-  }
-
-  public abstract static class ConnectivityDiagnosticsManager.ConnectivityDiagnosticsCallback {
-    ctor public ConnectivityDiagnosticsManager.ConnectivityDiagnosticsCallback();
-    method public void onConnectivityReportAvailable(@NonNull android.net.ConnectivityDiagnosticsManager.ConnectivityReport);
-    method public void onDataStallSuspected(@NonNull android.net.ConnectivityDiagnosticsManager.DataStallReport);
-    method public void onNetworkConnectivityReported(@NonNull android.net.Network, boolean);
-  }
-
-  public static final class ConnectivityDiagnosticsManager.ConnectivityReport implements android.os.Parcelable {
-    ctor public ConnectivityDiagnosticsManager.ConnectivityReport(@NonNull android.net.Network, long, @NonNull android.net.LinkProperties, @NonNull android.net.NetworkCapabilities, @NonNull android.os.PersistableBundle);
-    method public int describeContents();
-    method @NonNull public android.os.PersistableBundle getAdditionalInfo();
-    method @NonNull public android.net.LinkProperties getLinkProperties();
-    method @NonNull public android.net.Network getNetwork();
-    method @NonNull public android.net.NetworkCapabilities getNetworkCapabilities();
-    method public long getReportTimestamp();
-    method public void writeToParcel(@NonNull android.os.Parcel, int);
-    field @NonNull public static final android.os.Parcelable.Creator<android.net.ConnectivityDiagnosticsManager.ConnectivityReport> CREATOR;
-    field public static final String KEY_NETWORK_PROBES_ATTEMPTED_BITMASK = "networkProbesAttempted";
-    field public static final String KEY_NETWORK_PROBES_SUCCEEDED_BITMASK = "networkProbesSucceeded";
-    field public static final String KEY_NETWORK_VALIDATION_RESULT = "networkValidationResult";
-    field public static final int NETWORK_PROBE_DNS = 4; // 0x4
-    field public static final int NETWORK_PROBE_FALLBACK = 32; // 0x20
-    field public static final int NETWORK_PROBE_HTTP = 8; // 0x8
-    field public static final int NETWORK_PROBE_HTTPS = 16; // 0x10
-    field public static final int NETWORK_PROBE_PRIVATE_DNS = 64; // 0x40
-    field public static final int NETWORK_VALIDATION_RESULT_INVALID = 0; // 0x0
-    field public static final int NETWORK_VALIDATION_RESULT_PARTIALLY_VALID = 2; // 0x2
-    field public static final int NETWORK_VALIDATION_RESULT_SKIPPED = 3; // 0x3
-    field public static final int NETWORK_VALIDATION_RESULT_VALID = 1; // 0x1
-  }
-
-  public static final class ConnectivityDiagnosticsManager.DataStallReport implements android.os.Parcelable {
-    ctor public ConnectivityDiagnosticsManager.DataStallReport(@NonNull android.net.Network, long, int, @NonNull android.net.LinkProperties, @NonNull android.net.NetworkCapabilities, @NonNull android.os.PersistableBundle);
-    method public int describeContents();
-    method public int getDetectionMethod();
-    method @NonNull public android.net.LinkProperties getLinkProperties();
-    method @NonNull public android.net.Network getNetwork();
-    method @NonNull public android.net.NetworkCapabilities getNetworkCapabilities();
-    method public long getReportTimestamp();
-    method @NonNull public android.os.PersistableBundle getStallDetails();
-    method public void writeToParcel(@NonNull android.os.Parcel, int);
-    field @NonNull public static final android.os.Parcelable.Creator<android.net.ConnectivityDiagnosticsManager.DataStallReport> CREATOR;
-    field public static final int DETECTION_METHOD_DNS_EVENTS = 1; // 0x1
-    field public static final int DETECTION_METHOD_TCP_METRICS = 2; // 0x2
-    field public static final String KEY_DNS_CONSECUTIVE_TIMEOUTS = "dnsConsecutiveTimeouts";
-    field public static final String KEY_TCP_METRICS_COLLECTION_PERIOD_MILLIS = "tcpMetricsCollectionPeriodMillis";
-    field public static final String KEY_TCP_PACKET_FAIL_RATE = "tcpPacketFailRate";
-  }
-
-  public class ConnectivityManager {
-    method public void addDefaultNetworkActiveListener(android.net.ConnectivityManager.OnNetworkActiveListener);
-    method public boolean bindProcessToNetwork(@Nullable android.net.Network);
-    method @NonNull public android.net.SocketKeepalive createSocketKeepalive(@NonNull android.net.Network, @NonNull android.net.IpSecManager.UdpEncapsulationSocket, @NonNull java.net.InetAddress, @NonNull java.net.InetAddress, @NonNull java.util.concurrent.Executor, @NonNull android.net.SocketKeepalive.Callback);
-    method @Nullable @RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE) public android.net.Network getActiveNetwork();
-    method @Deprecated @Nullable @RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE) public android.net.NetworkInfo getActiveNetworkInfo();
-    method @Deprecated @NonNull @RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE) public android.net.NetworkInfo[] getAllNetworkInfo();
-    method @Deprecated @NonNull @RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE) public android.net.Network[] getAllNetworks();
-    method @Deprecated public boolean getBackgroundDataSetting();
-    method @Nullable public android.net.Network getBoundNetworkForProcess();
-    method public int getConnectionOwnerUid(int, @NonNull java.net.InetSocketAddress, @NonNull java.net.InetSocketAddress);
-    method @Nullable public android.net.ProxyInfo getDefaultProxy();
-    method @Nullable @RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE) public android.net.LinkProperties getLinkProperties(@Nullable android.net.Network);
-    method @RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE) public int getMultipathPreference(@Nullable android.net.Network);
-    method @Nullable @RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE) public android.net.NetworkCapabilities getNetworkCapabilities(@Nullable android.net.Network);
-    method @Deprecated @Nullable @RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE) public android.net.NetworkInfo getNetworkInfo(int);
-    method @Deprecated @Nullable @RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE) public android.net.NetworkInfo getNetworkInfo(@Nullable android.net.Network);
-    method @Deprecated @RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE) public int getNetworkPreference();
-    method @Nullable public byte[] getNetworkWatchlistConfigHash();
-    method @Deprecated @Nullable public static android.net.Network getProcessDefaultNetwork();
-    method public int getRestrictBackgroundStatus();
-    method @RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE) public boolean isActiveNetworkMetered();
-    method public boolean isDefaultNetworkActive();
-    method @Deprecated public static boolean isNetworkTypeValid(int);
-    method public void registerBestMatchingNetworkCallback(@NonNull android.net.NetworkRequest, @NonNull android.net.ConnectivityManager.NetworkCallback, @NonNull android.os.Handler);
-    method @RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE) public void registerDefaultNetworkCallback(@NonNull android.net.ConnectivityManager.NetworkCallback);
-    method @RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE) public void registerDefaultNetworkCallback(@NonNull android.net.ConnectivityManager.NetworkCallback, @NonNull android.os.Handler);
-    method @RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE) public void registerNetworkCallback(@NonNull android.net.NetworkRequest, @NonNull android.net.ConnectivityManager.NetworkCallback);
-    method @RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE) public void registerNetworkCallback(@NonNull android.net.NetworkRequest, @NonNull android.net.ConnectivityManager.NetworkCallback, @NonNull android.os.Handler);
-    method @RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE) public void registerNetworkCallback(@NonNull android.net.NetworkRequest, @NonNull android.app.PendingIntent);
-    method public void releaseNetworkRequest(@NonNull android.app.PendingIntent);
-    method public void removeDefaultNetworkActiveListener(@NonNull android.net.ConnectivityManager.OnNetworkActiveListener);
-    method @Deprecated public void reportBadNetwork(@Nullable android.net.Network);
-    method public void reportNetworkConnectivity(@Nullable android.net.Network, boolean);
-    method public boolean requestBandwidthUpdate(@NonNull android.net.Network);
-    method public void requestNetwork(@NonNull android.net.NetworkRequest, @NonNull android.net.ConnectivityManager.NetworkCallback);
-    method public void requestNetwork(@NonNull android.net.NetworkRequest, @NonNull android.net.ConnectivityManager.NetworkCallback, @NonNull android.os.Handler);
-    method public void requestNetwork(@NonNull android.net.NetworkRequest, @NonNull android.net.ConnectivityManager.NetworkCallback, int);
-    method public void requestNetwork(@NonNull android.net.NetworkRequest, @NonNull android.net.ConnectivityManager.NetworkCallback, @NonNull android.os.Handler, int);
-    method public void requestNetwork(@NonNull android.net.NetworkRequest, @NonNull android.app.PendingIntent);
-    method @Deprecated public void setNetworkPreference(int);
-    method @Deprecated public static boolean setProcessDefaultNetwork(@Nullable android.net.Network);
-    method public void unregisterNetworkCallback(@NonNull android.net.ConnectivityManager.NetworkCallback);
-    method public void unregisterNetworkCallback(@NonNull android.app.PendingIntent);
-    field @Deprecated public static final String ACTION_BACKGROUND_DATA_SETTING_CHANGED = "android.net.conn.BACKGROUND_DATA_SETTING_CHANGED";
-    field public static final String ACTION_CAPTIVE_PORTAL_SIGN_IN = "android.net.conn.CAPTIVE_PORTAL";
-    field public static final String ACTION_RESTRICT_BACKGROUND_CHANGED = "android.net.conn.RESTRICT_BACKGROUND_CHANGED";
-    field @Deprecated public static final String CONNECTIVITY_ACTION = "android.net.conn.CONNECTIVITY_CHANGE";
-    field @Deprecated public static final int DEFAULT_NETWORK_PREFERENCE = 1; // 0x1
-    field public static final String EXTRA_CAPTIVE_PORTAL = "android.net.extra.CAPTIVE_PORTAL";
-    field public static final String EXTRA_CAPTIVE_PORTAL_URL = "android.net.extra.CAPTIVE_PORTAL_URL";
-    field @Deprecated public static final String EXTRA_EXTRA_INFO = "extraInfo";
-    field @Deprecated public static final String EXTRA_IS_FAILOVER = "isFailover";
-    field public static final String EXTRA_NETWORK = "android.net.extra.NETWORK";
-    field @Deprecated public static final String EXTRA_NETWORK_INFO = "networkInfo";
-    field public static final String EXTRA_NETWORK_REQUEST = "android.net.extra.NETWORK_REQUEST";
-    field @Deprecated public static final String EXTRA_NETWORK_TYPE = "networkType";
-    field public static final String EXTRA_NO_CONNECTIVITY = "noConnectivity";
-    field @Deprecated public static final String EXTRA_OTHER_NETWORK_INFO = "otherNetwork";
-    field public static final String EXTRA_REASON = "reason";
-    field public static final int MULTIPATH_PREFERENCE_HANDOVER = 1; // 0x1
-    field public static final int MULTIPATH_PREFERENCE_PERFORMANCE = 4; // 0x4
-    field public static final int MULTIPATH_PREFERENCE_RELIABILITY = 2; // 0x2
-    field public static final int RESTRICT_BACKGROUND_STATUS_DISABLED = 1; // 0x1
-    field public static final int RESTRICT_BACKGROUND_STATUS_ENABLED = 3; // 0x3
-    field public static final int RESTRICT_BACKGROUND_STATUS_WHITELISTED = 2; // 0x2
-    field @Deprecated public static final int TYPE_BLUETOOTH = 7; // 0x7
-    field @Deprecated public static final int TYPE_DUMMY = 8; // 0x8
-    field @Deprecated public static final int TYPE_ETHERNET = 9; // 0x9
-    field @Deprecated public static final int TYPE_MOBILE = 0; // 0x0
-    field @Deprecated public static final int TYPE_MOBILE_DUN = 4; // 0x4
-    field @Deprecated public static final int TYPE_MOBILE_HIPRI = 5; // 0x5
-    field @Deprecated public static final int TYPE_MOBILE_MMS = 2; // 0x2
-    field @Deprecated public static final int TYPE_MOBILE_SUPL = 3; // 0x3
-    field @Deprecated public static final int TYPE_VPN = 17; // 0x11
-    field @Deprecated public static final int TYPE_WIFI = 1; // 0x1
-    field @Deprecated public static final int TYPE_WIMAX = 6; // 0x6
-  }
-
-  public static class ConnectivityManager.NetworkCallback {
-    ctor public ConnectivityManager.NetworkCallback();
-    ctor public ConnectivityManager.NetworkCallback(int);
-    method public void onAvailable(@NonNull android.net.Network);
-    method public void onBlockedStatusChanged(@NonNull android.net.Network, boolean);
-    method public void onCapabilitiesChanged(@NonNull android.net.Network, @NonNull android.net.NetworkCapabilities);
-    method public void onLinkPropertiesChanged(@NonNull android.net.Network, @NonNull android.net.LinkProperties);
-    method public void onLosing(@NonNull android.net.Network, int);
-    method public void onLost(@NonNull android.net.Network);
-    method public void onUnavailable();
-    field public static final int FLAG_INCLUDE_LOCATION_INFO = 1; // 0x1
-  }
-
-  public static interface ConnectivityManager.OnNetworkActiveListener {
-    method public void onNetworkActive();
-  }
-
-  public class DhcpInfo implements android.os.Parcelable {
-    ctor public DhcpInfo();
-    method public int describeContents();
-    method public void writeToParcel(android.os.Parcel, int);
-    field @NonNull public static final android.os.Parcelable.Creator<android.net.DhcpInfo> CREATOR;
-    field public int dns1;
-    field public int dns2;
-    field public int gateway;
-    field public int ipAddress;
-    field public int leaseDuration;
-    field public int netmask;
-    field public int serverAddress;
-  }
-
-  public final class DnsResolver {
-    method @NonNull public static android.net.DnsResolver getInstance();
-    method public void query(@Nullable android.net.Network, @NonNull String, int, @NonNull java.util.concurrent.Executor, @Nullable android.os.CancellationSignal, @NonNull android.net.DnsResolver.Callback<? super java.util.List<java.net.InetAddress>>);
-    method public void query(@Nullable android.net.Network, @NonNull String, int, int, @NonNull java.util.concurrent.Executor, @Nullable android.os.CancellationSignal, @NonNull android.net.DnsResolver.Callback<? super java.util.List<java.net.InetAddress>>);
-    method public void rawQuery(@Nullable android.net.Network, @NonNull byte[], int, @NonNull java.util.concurrent.Executor, @Nullable android.os.CancellationSignal, @NonNull android.net.DnsResolver.Callback<? super byte[]>);
-    method public void rawQuery(@Nullable android.net.Network, @NonNull String, int, int, int, @NonNull java.util.concurrent.Executor, @Nullable android.os.CancellationSignal, @NonNull android.net.DnsResolver.Callback<? super byte[]>);
-    field public static final int CLASS_IN = 1; // 0x1
-    field public static final int ERROR_PARSE = 0; // 0x0
-    field public static final int ERROR_SYSTEM = 1; // 0x1
-    field public static final int FLAG_EMPTY = 0; // 0x0
-    field public static final int FLAG_NO_CACHE_LOOKUP = 4; // 0x4
-    field public static final int FLAG_NO_CACHE_STORE = 2; // 0x2
-    field public static final int FLAG_NO_RETRY = 1; // 0x1
-    field public static final int TYPE_A = 1; // 0x1
-    field public static final int TYPE_AAAA = 28; // 0x1c
-  }
-
-  public static interface DnsResolver.Callback<T> {
-    method public void onAnswer(@NonNull T, int);
-    method public void onError(@NonNull android.net.DnsResolver.DnsException);
-  }
-
-  public static class DnsResolver.DnsException extends java.lang.Exception {
-    ctor public DnsResolver.DnsException(int, @Nullable Throwable);
-    field public final int code;
-  }
-
-  public class InetAddresses {
-    method public static boolean isNumericAddress(@NonNull String);
-    method @NonNull public static java.net.InetAddress parseNumericAddress(@NonNull String);
-  }
-
-  public final class IpConfiguration implements android.os.Parcelable {
-    method public int describeContents();
-    method @Nullable public android.net.ProxyInfo getHttpProxy();
-    method @Nullable public android.net.StaticIpConfiguration getStaticIpConfiguration();
-    method public void writeToParcel(@NonNull android.os.Parcel, int);
-    field @NonNull public static final android.os.Parcelable.Creator<android.net.IpConfiguration> CREATOR;
-  }
-
-  public static final class IpConfiguration.Builder {
-    ctor public IpConfiguration.Builder();
-    method @NonNull public android.net.IpConfiguration build();
-    method @NonNull public android.net.IpConfiguration.Builder setHttpProxy(@Nullable android.net.ProxyInfo);
-    method @NonNull public android.net.IpConfiguration.Builder setStaticIpConfiguration(@Nullable android.net.StaticIpConfiguration);
-  }
-
-  public final class IpPrefix implements android.os.Parcelable {
-    ctor public IpPrefix(@NonNull java.net.InetAddress, @IntRange(from=0, to=128) int);
-    method public boolean contains(@NonNull java.net.InetAddress);
-    method public int describeContents();
-    method @NonNull public java.net.InetAddress getAddress();
-    method @IntRange(from=0, to=128) public int getPrefixLength();
-    method @NonNull public byte[] getRawAddress();
-    method public void writeToParcel(android.os.Parcel, int);
-    field @NonNull public static final android.os.Parcelable.Creator<android.net.IpPrefix> CREATOR;
-  }
-
-  public class LinkAddress implements android.os.Parcelable {
-    method public int describeContents();
-    method public java.net.InetAddress getAddress();
-    method public int getFlags();
-    method @IntRange(from=0, to=128) public int getPrefixLength();
-    method public int getScope();
-    method public void writeToParcel(android.os.Parcel, int);
-    field @NonNull public static final android.os.Parcelable.Creator<android.net.LinkAddress> CREATOR;
-  }
-
-  public final class LinkProperties implements android.os.Parcelable {
-    ctor public LinkProperties();
-    method public boolean addRoute(@NonNull android.net.RouteInfo);
-    method public void clear();
-    method public int describeContents();
-    method @Nullable public java.net.Inet4Address getDhcpServerAddress();
-    method @NonNull public java.util.List<java.net.InetAddress> getDnsServers();
-    method @Nullable public String getDomains();
-    method @Nullable public android.net.ProxyInfo getHttpProxy();
-    method @Nullable public String getInterfaceName();
-    method @NonNull public java.util.List<android.net.LinkAddress> getLinkAddresses();
-    method public int getMtu();
-    method @Nullable public android.net.IpPrefix getNat64Prefix();
-    method @Nullable public String getPrivateDnsServerName();
-    method @NonNull public java.util.List<android.net.RouteInfo> getRoutes();
-    method public boolean isPrivateDnsActive();
-    method public boolean isWakeOnLanSupported();
-    method public void setDhcpServerAddress(@Nullable java.net.Inet4Address);
-    method public void setDnsServers(@NonNull java.util.Collection<java.net.InetAddress>);
-    method public void setDomains(@Nullable String);
-    method public void setHttpProxy(@Nullable android.net.ProxyInfo);
-    method public void setInterfaceName(@Nullable String);
-    method public void setLinkAddresses(@NonNull java.util.Collection<android.net.LinkAddress>);
-    method public void setMtu(int);
-    method public void setNat64Prefix(@Nullable android.net.IpPrefix);
-    method public void writeToParcel(android.os.Parcel, int);
-    field @NonNull public static final android.os.Parcelable.Creator<android.net.LinkProperties> CREATOR;
-  }
-
-  public final class MacAddress implements android.os.Parcelable {
-    method public int describeContents();
-    method @NonNull public static android.net.MacAddress fromBytes(@NonNull byte[]);
-    method @NonNull public static android.net.MacAddress fromString(@NonNull String);
-    method public int getAddressType();
-    method @Nullable public java.net.Inet6Address getLinkLocalIpv6FromEui48Mac();
-    method public boolean isLocallyAssigned();
-    method public boolean matches(@NonNull android.net.MacAddress, @NonNull android.net.MacAddress);
-    method @NonNull public byte[] toByteArray();
-    method @NonNull public String toOuiString();
-    method public void writeToParcel(android.os.Parcel, int);
-    field public static final android.net.MacAddress BROADCAST_ADDRESS;
-    field @NonNull public static final android.os.Parcelable.Creator<android.net.MacAddress> CREATOR;
-    field public static final int TYPE_BROADCAST = 3; // 0x3
-    field public static final int TYPE_MULTICAST = 2; // 0x2
-    field public static final int TYPE_UNICAST = 1; // 0x1
-  }
-
-  public class Network implements android.os.Parcelable {
-    method public void bindSocket(java.net.DatagramSocket) throws java.io.IOException;
-    method public void bindSocket(java.net.Socket) throws java.io.IOException;
-    method public void bindSocket(java.io.FileDescriptor) throws java.io.IOException;
-    method public int describeContents();
-    method public static android.net.Network fromNetworkHandle(long);
-    method public java.net.InetAddress[] getAllByName(String) throws java.net.UnknownHostException;
-    method public java.net.InetAddress getByName(String) throws java.net.UnknownHostException;
-    method public long getNetworkHandle();
-    method public javax.net.SocketFactory getSocketFactory();
-    method public java.net.URLConnection openConnection(java.net.URL) throws java.io.IOException;
-    method public java.net.URLConnection openConnection(java.net.URL, java.net.Proxy) throws java.io.IOException;
-    method public void writeToParcel(android.os.Parcel, int);
-    field @NonNull public static final android.os.Parcelable.Creator<android.net.Network> CREATOR;
-  }
-
-  public final class NetworkCapabilities implements android.os.Parcelable {
-    ctor public NetworkCapabilities();
-    ctor public NetworkCapabilities(android.net.NetworkCapabilities);
-    method public int describeContents();
-    method @NonNull public int[] getCapabilities();
-    method @NonNull public int[] getEnterpriseIds();
-    method public int getLinkDownstreamBandwidthKbps();
-    method public int getLinkUpstreamBandwidthKbps();
-    method @Nullable public android.net.NetworkSpecifier getNetworkSpecifier();
-    method public int getOwnerUid();
-    method public int getSignalStrength();
-    method @Nullable public android.net.TransportInfo getTransportInfo();
-    method public boolean hasCapability(int);
-    method public boolean hasEnterpriseId(int);
-    method public boolean hasTransport(int);
-    method public void writeToParcel(android.os.Parcel, int);
-    field @NonNull public static final android.os.Parcelable.Creator<android.net.NetworkCapabilities> CREATOR;
-    field public static final int NET_CAPABILITY_CAPTIVE_PORTAL = 17; // 0x11
-    field public static final int NET_CAPABILITY_CBS = 5; // 0x5
-    field public static final int NET_CAPABILITY_DUN = 2; // 0x2
-    field public static final int NET_CAPABILITY_EIMS = 10; // 0xa
-    field public static final int NET_CAPABILITY_ENTERPRISE = 29; // 0x1d
-    field public static final int NET_CAPABILITY_FOREGROUND = 19; // 0x13
-    field public static final int NET_CAPABILITY_FOTA = 3; // 0x3
-    field public static final int NET_CAPABILITY_HEAD_UNIT = 32; // 0x20
-    field public static final int NET_CAPABILITY_IA = 7; // 0x7
-    field public static final int NET_CAPABILITY_IMS = 4; // 0x4
-    field public static final int NET_CAPABILITY_INTERNET = 12; // 0xc
-    field public static final int NET_CAPABILITY_MCX = 23; // 0x17
-    field public static final int NET_CAPABILITY_MMS = 0; // 0x0
-    field public static final int NET_CAPABILITY_MMTEL = 33; // 0x21
-    field public static final int NET_CAPABILITY_NOT_CONGESTED = 20; // 0x14
-    field public static final int NET_CAPABILITY_NOT_METERED = 11; // 0xb
-    field public static final int NET_CAPABILITY_NOT_RESTRICTED = 13; // 0xd
-    field public static final int NET_CAPABILITY_NOT_ROAMING = 18; // 0x12
-    field public static final int NET_CAPABILITY_NOT_SUSPENDED = 21; // 0x15
-    field public static final int NET_CAPABILITY_NOT_VPN = 15; // 0xf
-    field public static final int NET_CAPABILITY_PRIORITIZE_BANDWIDTH = 35; // 0x23
-    field public static final int NET_CAPABILITY_PRIORITIZE_LATENCY = 34; // 0x22
-    field public static final int NET_CAPABILITY_RCS = 8; // 0x8
-    field public static final int NET_CAPABILITY_SUPL = 1; // 0x1
-    field public static final int NET_CAPABILITY_TEMPORARILY_NOT_METERED = 25; // 0x19
-    field public static final int NET_CAPABILITY_TRUSTED = 14; // 0xe
-    field public static final int NET_CAPABILITY_VALIDATED = 16; // 0x10
-    field public static final int NET_CAPABILITY_WIFI_P2P = 6; // 0x6
-    field public static final int NET_CAPABILITY_XCAP = 9; // 0x9
-    field public static final int NET_ENTERPRISE_ID_1 = 1; // 0x1
-    field public static final int NET_ENTERPRISE_ID_2 = 2; // 0x2
-    field public static final int NET_ENTERPRISE_ID_3 = 3; // 0x3
-    field public static final int NET_ENTERPRISE_ID_4 = 4; // 0x4
-    field public static final int NET_ENTERPRISE_ID_5 = 5; // 0x5
-    field public static final int SIGNAL_STRENGTH_UNSPECIFIED = -2147483648; // 0x80000000
-    field public static final int TRANSPORT_BLUETOOTH = 2; // 0x2
-    field public static final int TRANSPORT_CELLULAR = 0; // 0x0
-    field public static final int TRANSPORT_ETHERNET = 3; // 0x3
-    field public static final int TRANSPORT_LOWPAN = 6; // 0x6
-    field public static final int TRANSPORT_THREAD = 9; // 0x9
-    field public static final int TRANSPORT_USB = 8; // 0x8
-    field public static final int TRANSPORT_VPN = 4; // 0x4
-    field public static final int TRANSPORT_WIFI = 1; // 0x1
-    field public static final int TRANSPORT_WIFI_AWARE = 5; // 0x5
-  }
-
-  @Deprecated public class NetworkInfo implements android.os.Parcelable {
-    ctor @Deprecated public NetworkInfo(int, int, @Nullable String, @Nullable String);
-    method @Deprecated public int describeContents();
-    method @Deprecated @NonNull public android.net.NetworkInfo.DetailedState getDetailedState();
-    method @Deprecated public String getExtraInfo();
-    method @Deprecated public String getReason();
-    method @Deprecated public android.net.NetworkInfo.State getState();
-    method @Deprecated public int getSubtype();
-    method @Deprecated public String getSubtypeName();
-    method @Deprecated public int getType();
-    method @Deprecated public String getTypeName();
-    method @Deprecated public boolean isAvailable();
-    method @Deprecated public boolean isConnected();
-    method @Deprecated public boolean isConnectedOrConnecting();
-    method @Deprecated public boolean isFailover();
-    method @Deprecated public boolean isRoaming();
-    method @Deprecated public void setDetailedState(@NonNull android.net.NetworkInfo.DetailedState, @Nullable String, @Nullable String);
-    method @Deprecated public void writeToParcel(android.os.Parcel, int);
-    field @Deprecated @NonNull public static final android.os.Parcelable.Creator<android.net.NetworkInfo> CREATOR;
-  }
-
-  @Deprecated public enum NetworkInfo.DetailedState {
-    enum_constant @Deprecated public static final android.net.NetworkInfo.DetailedState AUTHENTICATING;
-    enum_constant @Deprecated public static final android.net.NetworkInfo.DetailedState BLOCKED;
-    enum_constant @Deprecated public static final android.net.NetworkInfo.DetailedState CAPTIVE_PORTAL_CHECK;
-    enum_constant @Deprecated public static final android.net.NetworkInfo.DetailedState CONNECTED;
-    enum_constant @Deprecated public static final android.net.NetworkInfo.DetailedState CONNECTING;
-    enum_constant @Deprecated public static final android.net.NetworkInfo.DetailedState DISCONNECTED;
-    enum_constant @Deprecated public static final android.net.NetworkInfo.DetailedState DISCONNECTING;
-    enum_constant @Deprecated public static final android.net.NetworkInfo.DetailedState FAILED;
-    enum_constant @Deprecated public static final android.net.NetworkInfo.DetailedState IDLE;
-    enum_constant @Deprecated public static final android.net.NetworkInfo.DetailedState OBTAINING_IPADDR;
-    enum_constant @Deprecated public static final android.net.NetworkInfo.DetailedState SCANNING;
-    enum_constant @Deprecated public static final android.net.NetworkInfo.DetailedState SUSPENDED;
-    enum_constant @Deprecated public static final android.net.NetworkInfo.DetailedState VERIFYING_POOR_LINK;
-  }
-
-  @Deprecated public enum NetworkInfo.State {
-    enum_constant @Deprecated public static final android.net.NetworkInfo.State CONNECTED;
-    enum_constant @Deprecated public static final android.net.NetworkInfo.State CONNECTING;
-    enum_constant @Deprecated public static final android.net.NetworkInfo.State DISCONNECTED;
-    enum_constant @Deprecated public static final android.net.NetworkInfo.State DISCONNECTING;
-    enum_constant @Deprecated public static final android.net.NetworkInfo.State SUSPENDED;
-    enum_constant @Deprecated public static final android.net.NetworkInfo.State UNKNOWN;
-  }
-
-  public class NetworkRequest implements android.os.Parcelable {
-    method public boolean canBeSatisfiedBy(@Nullable android.net.NetworkCapabilities);
-    method public int describeContents();
-    method @NonNull public int[] getCapabilities();
-    method @Nullable public android.net.NetworkSpecifier getNetworkSpecifier();
-    method @NonNull public int[] getTransportTypes();
-    method public boolean hasCapability(int);
-    method public boolean hasTransport(int);
-    method public void writeToParcel(android.os.Parcel, int);
-    field @NonNull public static final android.os.Parcelable.Creator<android.net.NetworkRequest> CREATOR;
-  }
-
-  public static class NetworkRequest.Builder {
-    ctor public NetworkRequest.Builder();
-    ctor public NetworkRequest.Builder(@NonNull android.net.NetworkRequest);
-    method public android.net.NetworkRequest.Builder addCapability(int);
-    method public android.net.NetworkRequest.Builder addTransportType(int);
-    method public android.net.NetworkRequest build();
-    method @NonNull public android.net.NetworkRequest.Builder clearCapabilities();
-    method public android.net.NetworkRequest.Builder removeCapability(int);
-    method public android.net.NetworkRequest.Builder removeTransportType(int);
-    method @NonNull public android.net.NetworkRequest.Builder setIncludeOtherUidNetworks(boolean);
-    method @Deprecated public android.net.NetworkRequest.Builder setNetworkSpecifier(String);
-    method public android.net.NetworkRequest.Builder setNetworkSpecifier(android.net.NetworkSpecifier);
-  }
-
-  public class ParseException extends java.lang.RuntimeException {
-    ctor public ParseException(@NonNull String);
-    ctor public ParseException(@NonNull String, @NonNull Throwable);
-    field public String response;
-  }
-
-  public class ProxyInfo implements android.os.Parcelable {
-    ctor public ProxyInfo(@Nullable android.net.ProxyInfo);
-    method public static android.net.ProxyInfo buildDirectProxy(String, int);
-    method public static android.net.ProxyInfo buildDirectProxy(String, int, java.util.List<java.lang.String>);
-    method public static android.net.ProxyInfo buildPacProxy(android.net.Uri);
-    method @NonNull public static android.net.ProxyInfo buildPacProxy(@NonNull android.net.Uri, int);
-    method public int describeContents();
-    method public String[] getExclusionList();
-    method public String getHost();
-    method public android.net.Uri getPacFileUrl();
-    method public int getPort();
-    method public boolean isValid();
-    method public void writeToParcel(android.os.Parcel, int);
-    field @NonNull public static final android.os.Parcelable.Creator<android.net.ProxyInfo> CREATOR;
-  }
-
-  public final class RouteInfo implements android.os.Parcelable {
-    method public int describeContents();
-    method @NonNull public android.net.IpPrefix getDestination();
-    method @Nullable public java.net.InetAddress getGateway();
-    method @Nullable public String getInterface();
-    method public int getType();
-    method public boolean hasGateway();
-    method public boolean isDefaultRoute();
-    method public boolean matches(java.net.InetAddress);
-    method public void writeToParcel(android.os.Parcel, int);
-    field @NonNull public static final android.os.Parcelable.Creator<android.net.RouteInfo> CREATOR;
-    field public static final int RTN_THROW = 9; // 0x9
-    field public static final int RTN_UNICAST = 1; // 0x1
-    field public static final int RTN_UNREACHABLE = 7; // 0x7
-  }
-
-  public abstract class SocketKeepalive implements java.lang.AutoCloseable {
-    method public final void close();
-    method public final void start(@IntRange(from=0xa, to=0xe10) int);
-    method public final void stop();
-    field public static final int ERROR_HARDWARE_ERROR = -31; // 0xffffffe1
-    field public static final int ERROR_INSUFFICIENT_RESOURCES = -32; // 0xffffffe0
-    field public static final int ERROR_INVALID_INTERVAL = -24; // 0xffffffe8
-    field public static final int ERROR_INVALID_IP_ADDRESS = -21; // 0xffffffeb
-    field public static final int ERROR_INVALID_LENGTH = -23; // 0xffffffe9
-    field public static final int ERROR_INVALID_NETWORK = -20; // 0xffffffec
-    field public static final int ERROR_INVALID_PORT = -22; // 0xffffffea
-    field public static final int ERROR_INVALID_SOCKET = -25; // 0xffffffe7
-    field public static final int ERROR_SOCKET_NOT_IDLE = -26; // 0xffffffe6
-    field public static final int ERROR_UNSUPPORTED = -30; // 0xffffffe2
-  }
-
-  public static class SocketKeepalive.Callback {
-    ctor public SocketKeepalive.Callback();
-    method public void onDataReceived();
-    method public void onError(int);
-    method public void onStarted();
-    method public void onStopped();
-  }
-
-  public final class StaticIpConfiguration implements android.os.Parcelable {
-    method public int describeContents();
-    method @NonNull public java.util.List<java.net.InetAddress> getDnsServers();
-    method @Nullable public String getDomains();
-    method @Nullable public java.net.InetAddress getGateway();
-    method @NonNull public android.net.LinkAddress getIpAddress();
-    method public void writeToParcel(@NonNull android.os.Parcel, int);
-    field @NonNull public static final android.os.Parcelable.Creator<android.net.StaticIpConfiguration> CREATOR;
-  }
-
-  public static final class StaticIpConfiguration.Builder {
-    ctor public StaticIpConfiguration.Builder();
-    method @NonNull public android.net.StaticIpConfiguration build();
-    method @NonNull public android.net.StaticIpConfiguration.Builder setDnsServers(@NonNull Iterable<java.net.InetAddress>);
-    method @NonNull public android.net.StaticIpConfiguration.Builder setDomains(@Nullable String);
-    method @NonNull public android.net.StaticIpConfiguration.Builder setGateway(@Nullable java.net.InetAddress);
-    method @NonNull public android.net.StaticIpConfiguration.Builder setIpAddress(@NonNull android.net.LinkAddress);
-  }
-
-  public interface TransportInfo {
-  }
-
-}
-
diff --git a/framework/cronet_disabled/api/lint-baseline.txt b/framework/cronet_disabled/api/lint-baseline.txt
deleted file mode 100644
index 2f4004a..0000000
--- a/framework/cronet_disabled/api/lint-baseline.txt
+++ /dev/null
@@ -1,4 +0,0 @@
-// Baseline format: 1.0
-VisiblySynchronized: android.net.NetworkInfo#toString():
-    Internal locks must not be exposed (synchronizing on this or class is still
-    externally observable): method android.net.NetworkInfo.toString()
diff --git a/framework/cronet_disabled/api/module-lib-current.txt b/framework/cronet_disabled/api/module-lib-current.txt
deleted file mode 100644
index 193bd92..0000000
--- a/framework/cronet_disabled/api/module-lib-current.txt
+++ /dev/null
@@ -1,239 +0,0 @@
-// Signature format: 2.0
-package android.net {
-
-  public final class ConnectivityFrameworkInitializer {
-    method public static void registerServiceWrappers();
-  }
-
-  public class ConnectivityManager {
-    method @RequiresPermission(anyOf={android.Manifest.permission.NETWORK_SETTINGS, android.Manifest.permission.NETWORK_STACK, android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK}) public void addUidToMeteredNetworkAllowList(int);
-    method @RequiresPermission(anyOf={android.Manifest.permission.NETWORK_SETTINGS, android.Manifest.permission.NETWORK_STACK, android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK}) public void addUidToMeteredNetworkDenyList(int);
-    method @RequiresPermission(anyOf={android.Manifest.permission.NETWORK_SETTINGS, android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK}) public void factoryReset();
-    method @NonNull @RequiresPermission(anyOf={android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK, android.Manifest.permission.NETWORK_STACK, android.Manifest.permission.NETWORK_SETTINGS}) public java.util.List<android.net.NetworkStateSnapshot> getAllNetworkStateSnapshots();
-    method @Nullable public android.net.ProxyInfo getGlobalProxy();
-    method @NonNull public static android.util.Range<java.lang.Integer> getIpSecNetIdRange();
-    method @Nullable @RequiresPermission(anyOf={android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK, android.Manifest.permission.NETWORK_STACK, android.Manifest.permission.NETWORK_SETTINGS}) public android.net.LinkProperties getRedactedLinkPropertiesForPackage(@NonNull android.net.LinkProperties, int, @NonNull String);
-    method @Nullable @RequiresPermission(anyOf={android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK, android.Manifest.permission.NETWORK_STACK, android.Manifest.permission.NETWORK_SETTINGS}) public android.net.NetworkCapabilities getRedactedNetworkCapabilitiesForPackage(@NonNull android.net.NetworkCapabilities, int, @NonNull String);
-    method @RequiresPermission(anyOf={android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK, android.Manifest.permission.NETWORK_SETTINGS}) public void registerDefaultNetworkCallbackForUid(int, @NonNull android.net.ConnectivityManager.NetworkCallback, @NonNull android.os.Handler);
-    method @RequiresPermission(anyOf={android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK, android.Manifest.permission.NETWORK_SETTINGS, android.Manifest.permission.NETWORK_SETUP_WIZARD, android.Manifest.permission.CONNECTIVITY_USE_RESTRICTED_NETWORKS}) public void registerSystemDefaultNetworkCallback(@NonNull android.net.ConnectivityManager.NetworkCallback, @NonNull android.os.Handler);
-    method @RequiresPermission(anyOf={android.Manifest.permission.NETWORK_SETTINGS, android.Manifest.permission.NETWORK_STACK, android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK}) public void removeUidFromMeteredNetworkAllowList(int);
-    method @RequiresPermission(anyOf={android.Manifest.permission.NETWORK_SETTINGS, android.Manifest.permission.NETWORK_STACK, android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK}) public void removeUidFromMeteredNetworkDenyList(int);
-    method @RequiresPermission(anyOf={android.Manifest.permission.NETWORK_SETTINGS, android.Manifest.permission.NETWORK_STACK, android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK}) public void replaceFirewallChain(int, @NonNull int[]);
-    method @RequiresPermission(anyOf={android.Manifest.permission.NETWORK_SETTINGS, android.Manifest.permission.NETWORK_STACK, android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK}) public void requestBackgroundNetwork(@NonNull android.net.NetworkRequest, @NonNull android.net.ConnectivityManager.NetworkCallback, @NonNull android.os.Handler);
-    method @Deprecated public boolean requestRouteToHostAddress(int, java.net.InetAddress);
-    method @RequiresPermission(anyOf={android.Manifest.permission.NETWORK_SETTINGS, android.Manifest.permission.NETWORK_SETUP_WIZARD, android.Manifest.permission.NETWORK_STACK, android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK}) public void setAcceptPartialConnectivity(@NonNull android.net.Network, boolean, boolean);
-    method @RequiresPermission(anyOf={android.Manifest.permission.NETWORK_SETTINGS, android.Manifest.permission.NETWORK_SETUP_WIZARD, android.Manifest.permission.NETWORK_STACK, android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK}) public void setAcceptUnvalidated(@NonNull android.net.Network, boolean, boolean);
-    method @RequiresPermission(anyOf={android.Manifest.permission.NETWORK_SETTINGS, android.Manifest.permission.NETWORK_SETUP_WIZARD, android.Manifest.permission.NETWORK_STACK, android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK}) public void setAvoidUnvalidated(@NonNull android.net.Network);
-    method @RequiresPermission(anyOf={android.Manifest.permission.NETWORK_SETTINGS, android.Manifest.permission.NETWORK_STACK, android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK}) public void setFirewallChainEnabled(int, boolean);
-    method @RequiresPermission(android.Manifest.permission.NETWORK_STACK) public void setGlobalProxy(@Nullable android.net.ProxyInfo);
-    method @RequiresPermission(anyOf={android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK, android.Manifest.permission.NETWORK_STACK, android.Manifest.permission.NETWORK_SETTINGS}) public void setLegacyLockdownVpnEnabled(boolean);
-    method @Deprecated @RequiresPermission(android.Manifest.permission.NETWORK_STACK) public void setProfileNetworkPreference(@NonNull android.os.UserHandle, int, @Nullable java.util.concurrent.Executor, @Nullable Runnable);
-    method @RequiresPermission(android.Manifest.permission.NETWORK_STACK) public void setProfileNetworkPreferences(@NonNull android.os.UserHandle, @NonNull java.util.List<android.net.ProfileNetworkPreference>, @Nullable java.util.concurrent.Executor, @Nullable Runnable);
-    method @RequiresPermission(anyOf={android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK, android.Manifest.permission.NETWORK_STACK, android.Manifest.permission.NETWORK_SETTINGS}) public void setRequireVpnForUids(boolean, @NonNull java.util.Collection<android.util.Range<java.lang.Integer>>);
-    method @RequiresPermission(anyOf={android.Manifest.permission.NETWORK_SETTINGS, android.Manifest.permission.NETWORK_STACK, android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK}) public void setUidFirewallRule(int, int, int);
-    method @RequiresPermission(anyOf={android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK, android.Manifest.permission.NETWORK_STACK, android.Manifest.permission.NETWORK_SETTINGS}) public void setVpnDefaultForUids(@NonNull String, @NonNull java.util.Collection<android.util.Range<java.lang.Integer>>);
-    method @RequiresPermission(anyOf={android.Manifest.permission.MANAGE_TEST_NETWORKS, android.Manifest.permission.NETWORK_STACK}) public void simulateDataStall(int, long, @NonNull android.net.Network, @NonNull android.os.PersistableBundle);
-    method @RequiresPermission(anyOf={android.Manifest.permission.NETWORK_SETTINGS, android.Manifest.permission.NETWORK_STACK, android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK}) public void startCaptivePortalApp(@NonNull android.net.Network);
-    method public void systemReady();
-    field public static final String ACTION_CLEAR_DNS_CACHE = "android.net.action.CLEAR_DNS_CACHE";
-    field public static final String ACTION_PROMPT_LOST_VALIDATION = "android.net.action.PROMPT_LOST_VALIDATION";
-    field public static final String ACTION_PROMPT_PARTIAL_CONNECTIVITY = "android.net.action.PROMPT_PARTIAL_CONNECTIVITY";
-    field public static final String ACTION_PROMPT_UNVALIDATED = "android.net.action.PROMPT_UNVALIDATED";
-    field public static final int BLOCKED_METERED_REASON_ADMIN_DISABLED = 262144; // 0x40000
-    field public static final int BLOCKED_METERED_REASON_DATA_SAVER = 65536; // 0x10000
-    field public static final int BLOCKED_METERED_REASON_MASK = -65536; // 0xffff0000
-    field public static final int BLOCKED_METERED_REASON_USER_RESTRICTED = 131072; // 0x20000
-    field public static final int BLOCKED_REASON_APP_STANDBY = 4; // 0x4
-    field public static final int BLOCKED_REASON_BATTERY_SAVER = 1; // 0x1
-    field public static final int BLOCKED_REASON_DOZE = 2; // 0x2
-    field public static final int BLOCKED_REASON_LOCKDOWN_VPN = 16; // 0x10
-    field public static final int BLOCKED_REASON_LOW_POWER_STANDBY = 32; // 0x20
-    field public static final int BLOCKED_REASON_NONE = 0; // 0x0
-    field public static final int BLOCKED_REASON_RESTRICTED_MODE = 8; // 0x8
-    field public static final int FIREWALL_CHAIN_DOZABLE = 1; // 0x1
-    field public static final int FIREWALL_CHAIN_LOW_POWER_STANDBY = 5; // 0x5
-    field public static final int FIREWALL_CHAIN_OEM_DENY_1 = 7; // 0x7
-    field public static final int FIREWALL_CHAIN_OEM_DENY_2 = 8; // 0x8
-    field public static final int FIREWALL_CHAIN_OEM_DENY_3 = 9; // 0x9
-    field public static final int FIREWALL_CHAIN_POWERSAVE = 3; // 0x3
-    field public static final int FIREWALL_CHAIN_RESTRICTED = 4; // 0x4
-    field public static final int FIREWALL_CHAIN_STANDBY = 2; // 0x2
-    field public static final int FIREWALL_RULE_ALLOW = 1; // 0x1
-    field public static final int FIREWALL_RULE_DEFAULT = 0; // 0x0
-    field public static final int FIREWALL_RULE_DENY = 2; // 0x2
-    field public static final int PROFILE_NETWORK_PREFERENCE_DEFAULT = 0; // 0x0
-    field public static final int PROFILE_NETWORK_PREFERENCE_ENTERPRISE = 1; // 0x1
-    field public static final int PROFILE_NETWORK_PREFERENCE_ENTERPRISE_BLOCKING = 3; // 0x3
-    field public static final int PROFILE_NETWORK_PREFERENCE_ENTERPRISE_NO_FALLBACK = 2; // 0x2
-  }
-
-  public static class ConnectivityManager.NetworkCallback {
-    method public void onBlockedStatusChanged(@NonNull android.net.Network, int);
-  }
-
-  public class ConnectivitySettingsManager {
-    method public static void clearGlobalProxy(@NonNull android.content.Context);
-    method @Nullable public static String getCaptivePortalHttpUrl(@NonNull android.content.Context);
-    method public static int getCaptivePortalMode(@NonNull android.content.Context, int);
-    method @NonNull public static java.time.Duration getConnectivityKeepPendingIntentDuration(@NonNull android.content.Context, @NonNull java.time.Duration);
-    method @NonNull public static android.util.Range<java.lang.Integer> getDnsResolverSampleRanges(@NonNull android.content.Context);
-    method @NonNull public static java.time.Duration getDnsResolverSampleValidityDuration(@NonNull android.content.Context, @NonNull java.time.Duration);
-    method public static int getDnsResolverSuccessThresholdPercent(@NonNull android.content.Context, int);
-    method @Nullable public static android.net.ProxyInfo getGlobalProxy(@NonNull android.content.Context);
-    method public static long getIngressRateLimitInBytesPerSecond(@NonNull android.content.Context);
-    method @NonNull public static java.time.Duration getMobileDataActivityTimeout(@NonNull android.content.Context, @NonNull java.time.Duration);
-    method public static boolean getMobileDataAlwaysOn(@NonNull android.content.Context, boolean);
-    method @NonNull public static java.util.Set<java.lang.Integer> getMobileDataPreferredUids(@NonNull android.content.Context);
-    method public static int getNetworkAvoidBadWifi(@NonNull android.content.Context);
-    method @Nullable public static String getNetworkMeteredMultipathPreference(@NonNull android.content.Context);
-    method public static int getNetworkSwitchNotificationMaximumDailyCount(@NonNull android.content.Context, int);
-    method @NonNull public static java.time.Duration getNetworkSwitchNotificationRateDuration(@NonNull android.content.Context, @NonNull java.time.Duration);
-    method @NonNull public static String getPrivateDnsDefaultMode(@NonNull android.content.Context);
-    method @Nullable public static String getPrivateDnsHostname(@NonNull android.content.Context);
-    method public static int getPrivateDnsMode(@NonNull android.content.Context);
-    method @NonNull public static java.util.Set<java.lang.Integer> getUidsAllowedOnRestrictedNetworks(@NonNull android.content.Context);
-    method public static boolean getWifiAlwaysRequested(@NonNull android.content.Context, boolean);
-    method @NonNull public static java.time.Duration getWifiDataActivityTimeout(@NonNull android.content.Context, @NonNull java.time.Duration);
-    method public static void setCaptivePortalHttpUrl(@NonNull android.content.Context, @Nullable String);
-    method public static void setCaptivePortalMode(@NonNull android.content.Context, int);
-    method public static void setConnectivityKeepPendingIntentDuration(@NonNull android.content.Context, @NonNull java.time.Duration);
-    method public static void setDnsResolverSampleRanges(@NonNull android.content.Context, @NonNull android.util.Range<java.lang.Integer>);
-    method public static void setDnsResolverSampleValidityDuration(@NonNull android.content.Context, @NonNull java.time.Duration);
-    method public static void setDnsResolverSuccessThresholdPercent(@NonNull android.content.Context, @IntRange(from=0, to=100) int);
-    method public static void setGlobalProxy(@NonNull android.content.Context, @NonNull android.net.ProxyInfo);
-    method public static void setIngressRateLimitInBytesPerSecond(@NonNull android.content.Context, @IntRange(from=-1L, to=4294967295L) long);
-    method public static void setMobileDataActivityTimeout(@NonNull android.content.Context, @NonNull java.time.Duration);
-    method public static void setMobileDataAlwaysOn(@NonNull android.content.Context, boolean);
-    method public static void setMobileDataPreferredUids(@NonNull android.content.Context, @NonNull java.util.Set<java.lang.Integer>);
-    method public static void setNetworkAvoidBadWifi(@NonNull android.content.Context, int);
-    method public static void setNetworkMeteredMultipathPreference(@NonNull android.content.Context, @NonNull String);
-    method public static void setNetworkSwitchNotificationMaximumDailyCount(@NonNull android.content.Context, @IntRange(from=0) int);
-    method public static void setNetworkSwitchNotificationRateDuration(@NonNull android.content.Context, @NonNull java.time.Duration);
-    method public static void setPrivateDnsDefaultMode(@NonNull android.content.Context, @NonNull int);
-    method public static void setPrivateDnsHostname(@NonNull android.content.Context, @Nullable String);
-    method public static void setPrivateDnsMode(@NonNull android.content.Context, int);
-    method public static void setUidsAllowedOnRestrictedNetworks(@NonNull android.content.Context, @NonNull java.util.Set<java.lang.Integer>);
-    method public static void setWifiAlwaysRequested(@NonNull android.content.Context, boolean);
-    method public static void setWifiDataActivityTimeout(@NonNull android.content.Context, @NonNull java.time.Duration);
-    field public static final int CAPTIVE_PORTAL_MODE_AVOID = 2; // 0x2
-    field public static final int CAPTIVE_PORTAL_MODE_IGNORE = 0; // 0x0
-    field public static final int CAPTIVE_PORTAL_MODE_PROMPT = 1; // 0x1
-    field public static final int NETWORK_AVOID_BAD_WIFI_AVOID = 2; // 0x2
-    field public static final int NETWORK_AVOID_BAD_WIFI_IGNORE = 0; // 0x0
-    field public static final int NETWORK_AVOID_BAD_WIFI_PROMPT = 1; // 0x1
-    field public static final int PRIVATE_DNS_MODE_OFF = 1; // 0x1
-    field public static final int PRIVATE_DNS_MODE_OPPORTUNISTIC = 2; // 0x2
-    field public static final int PRIVATE_DNS_MODE_PROVIDER_HOSTNAME = 3; // 0x3
-  }
-
-  public final class DhcpOption implements android.os.Parcelable {
-    ctor public DhcpOption(byte, @Nullable byte[]);
-    method public int describeContents();
-    method public byte getType();
-    method @Nullable public byte[] getValue();
-    method public void writeToParcel(@NonNull android.os.Parcel, int);
-    field @NonNull public static final android.os.Parcelable.Creator<android.net.DhcpOption> CREATOR;
-  }
-
-  public final class NetworkAgentConfig implements android.os.Parcelable {
-    method @Nullable public String getSubscriberId();
-    method public boolean isBypassableVpn();
-    method public boolean isVpnValidationRequired();
-  }
-
-  public static final class NetworkAgentConfig.Builder {
-    method @NonNull public android.net.NetworkAgentConfig.Builder setBypassableVpn(boolean);
-    method @NonNull public android.net.NetworkAgentConfig.Builder setLocalRoutesExcludedForVpn(boolean);
-    method @NonNull public android.net.NetworkAgentConfig.Builder setSubscriberId(@Nullable String);
-    method @NonNull public android.net.NetworkAgentConfig.Builder setVpnRequiresValidation(boolean);
-  }
-
-  public final class NetworkCapabilities implements android.os.Parcelable {
-    method @NonNull @RequiresPermission(android.Manifest.permission.NETWORK_FACTORY) public java.util.Set<java.lang.Integer> getAllowedUids();
-    method @Nullable public java.util.Set<android.util.Range<java.lang.Integer>> getUids();
-    method public boolean hasForbiddenCapability(int);
-    field public static final long REDACT_ALL = -1L; // 0xffffffffffffffffL
-    field public static final long REDACT_FOR_ACCESS_FINE_LOCATION = 1L; // 0x1L
-    field public static final long REDACT_FOR_LOCAL_MAC_ADDRESS = 2L; // 0x2L
-    field public static final long REDACT_FOR_NETWORK_SETTINGS = 4L; // 0x4L
-    field public static final long REDACT_NONE = 0L; // 0x0L
-    field public static final int TRANSPORT_TEST = 7; // 0x7
-  }
-
-  public static final class NetworkCapabilities.Builder {
-    method @NonNull @RequiresPermission(android.Manifest.permission.NETWORK_FACTORY) public android.net.NetworkCapabilities.Builder setAllowedUids(@NonNull java.util.Set<java.lang.Integer>);
-    method @NonNull public android.net.NetworkCapabilities.Builder setUids(@Nullable java.util.Set<android.util.Range<java.lang.Integer>>);
-  }
-
-  public class NetworkRequest implements android.os.Parcelable {
-    method @NonNull public int[] getEnterpriseIds();
-    method @NonNull public int[] getForbiddenCapabilities();
-    method public boolean hasEnterpriseId(int);
-    method public boolean hasForbiddenCapability(int);
-  }
-
-  public static class NetworkRequest.Builder {
-    method @NonNull public android.net.NetworkRequest.Builder addForbiddenCapability(int);
-    method @NonNull public android.net.NetworkRequest.Builder removeForbiddenCapability(int);
-    method @NonNull public android.net.NetworkRequest.Builder setUids(@Nullable java.util.Set<android.util.Range<java.lang.Integer>>);
-  }
-
-  public final class ProfileNetworkPreference implements android.os.Parcelable {
-    method public int describeContents();
-    method @NonNull public int[] getExcludedUids();
-    method @NonNull public int[] getIncludedUids();
-    method public int getPreference();
-    method public int getPreferenceEnterpriseId();
-    method public void writeToParcel(@NonNull android.os.Parcel, int);
-    field @NonNull public static final android.os.Parcelable.Creator<android.net.ProfileNetworkPreference> CREATOR;
-  }
-
-  public static final class ProfileNetworkPreference.Builder {
-    ctor public ProfileNetworkPreference.Builder();
-    method @NonNull public android.net.ProfileNetworkPreference build();
-    method @NonNull public android.net.ProfileNetworkPreference.Builder setExcludedUids(@NonNull int[]);
-    method @NonNull public android.net.ProfileNetworkPreference.Builder setIncludedUids(@NonNull int[]);
-    method @NonNull public android.net.ProfileNetworkPreference.Builder setPreference(int);
-    method @NonNull public android.net.ProfileNetworkPreference.Builder setPreferenceEnterpriseId(int);
-  }
-
-  public final class TestNetworkInterface implements android.os.Parcelable {
-    ctor public TestNetworkInterface(@NonNull android.os.ParcelFileDescriptor, @NonNull String);
-    method public int describeContents();
-    method @NonNull public android.os.ParcelFileDescriptor getFileDescriptor();
-    method @NonNull public String getInterfaceName();
-    method @Nullable public android.net.MacAddress getMacAddress();
-    method public int getMtu();
-    method public void writeToParcel(@NonNull android.os.Parcel, int);
-    field @NonNull public static final android.os.Parcelable.Creator<android.net.TestNetworkInterface> CREATOR;
-  }
-
-  public class TestNetworkManager {
-    method @NonNull @RequiresPermission(android.Manifest.permission.MANAGE_TEST_NETWORKS) public android.net.TestNetworkInterface createTapInterface();
-    method @NonNull @RequiresPermission(android.Manifest.permission.MANAGE_TEST_NETWORKS) public android.net.TestNetworkInterface createTunInterface(@NonNull java.util.Collection<android.net.LinkAddress>);
-    method @RequiresPermission(android.Manifest.permission.MANAGE_TEST_NETWORKS) public void setupTestNetwork(@NonNull String, @NonNull android.os.IBinder);
-    method @RequiresPermission(android.Manifest.permission.MANAGE_TEST_NETWORKS) public void teardownTestNetwork(@NonNull android.net.Network);
-    field public static final String TEST_TAP_PREFIX = "testtap";
-  }
-
-  public final class TestNetworkSpecifier extends android.net.NetworkSpecifier implements android.os.Parcelable {
-    ctor public TestNetworkSpecifier(@NonNull String);
-    method public int describeContents();
-    method @Nullable public String getInterfaceName();
-    method public void writeToParcel(@NonNull android.os.Parcel, int);
-    field @NonNull public static final android.os.Parcelable.Creator<android.net.TestNetworkSpecifier> CREATOR;
-  }
-
-  public interface TransportInfo {
-    method public default long getApplicableRedactions();
-    method @NonNull public default android.net.TransportInfo makeCopy(long);
-  }
-
-  public final class VpnTransportInfo implements android.os.Parcelable android.net.TransportInfo {
-    ctor @Deprecated public VpnTransportInfo(int, @Nullable String);
-    method @Nullable public String getSessionId();
-    method @NonNull public android.net.VpnTransportInfo makeCopy(long);
-  }
-
-}
-
diff --git a/framework/cronet_disabled/api/module-lib-removed.txt b/framework/cronet_disabled/api/module-lib-removed.txt
deleted file mode 100644
index d802177..0000000
--- a/framework/cronet_disabled/api/module-lib-removed.txt
+++ /dev/null
@@ -1 +0,0 @@
-// Signature format: 2.0
diff --git a/framework/cronet_disabled/api/removed.txt b/framework/cronet_disabled/api/removed.txt
deleted file mode 100644
index 303a1e6..0000000
--- a/framework/cronet_disabled/api/removed.txt
+++ /dev/null
@@ -1,11 +0,0 @@
-// Signature format: 2.0
-package android.net {
-
-  public class ConnectivityManager {
-    method @Deprecated public boolean requestRouteToHost(int, int);
-    method @Deprecated public int startUsingNetworkFeature(int, String);
-    method @Deprecated public int stopUsingNetworkFeature(int, String);
-  }
-
-}
-
diff --git a/framework/cronet_disabled/api/system-current.txt b/framework/cronet_disabled/api/system-current.txt
deleted file mode 100644
index 4a2ed8a..0000000
--- a/framework/cronet_disabled/api/system-current.txt
+++ /dev/null
@@ -1,544 +0,0 @@
-// Signature format: 2.0
-package android.net {
-
-  public class CaptivePortal implements android.os.Parcelable {
-    method @Deprecated public void logEvent(int, @NonNull String);
-    method @RequiresPermission(android.Manifest.permission.NETWORK_STACK) public void reevaluateNetwork();
-    method public void useNetwork();
-    field public static final int APP_REQUEST_REEVALUATION_REQUIRED = 100; // 0x64
-    field public static final int APP_RETURN_DISMISSED = 0; // 0x0
-    field public static final int APP_RETURN_UNWANTED = 1; // 0x1
-    field public static final int APP_RETURN_WANTED_AS_IS = 2; // 0x2
-  }
-
-  public final class CaptivePortalData implements android.os.Parcelable {
-    method public int describeContents();
-    method public long getByteLimit();
-    method public long getExpiryTimeMillis();
-    method public long getRefreshTimeMillis();
-    method @Nullable public android.net.Uri getUserPortalUrl();
-    method public int getUserPortalUrlSource();
-    method @Nullable public CharSequence getVenueFriendlyName();
-    method @Nullable public android.net.Uri getVenueInfoUrl();
-    method public int getVenueInfoUrlSource();
-    method public boolean isCaptive();
-    method public boolean isSessionExtendable();
-    method public void writeToParcel(@NonNull android.os.Parcel, int);
-    field public static final int CAPTIVE_PORTAL_DATA_SOURCE_OTHER = 0; // 0x0
-    field public static final int CAPTIVE_PORTAL_DATA_SOURCE_PASSPOINT = 1; // 0x1
-    field @NonNull public static final android.os.Parcelable.Creator<android.net.CaptivePortalData> CREATOR;
-  }
-
-  public static class CaptivePortalData.Builder {
-    ctor public CaptivePortalData.Builder();
-    ctor public CaptivePortalData.Builder(@Nullable android.net.CaptivePortalData);
-    method @NonNull public android.net.CaptivePortalData build();
-    method @NonNull public android.net.CaptivePortalData.Builder setBytesRemaining(long);
-    method @NonNull public android.net.CaptivePortalData.Builder setCaptive(boolean);
-    method @NonNull public android.net.CaptivePortalData.Builder setExpiryTime(long);
-    method @NonNull public android.net.CaptivePortalData.Builder setRefreshTime(long);
-    method @NonNull public android.net.CaptivePortalData.Builder setSessionExtendable(boolean);
-    method @NonNull public android.net.CaptivePortalData.Builder setUserPortalUrl(@Nullable android.net.Uri);
-    method @NonNull public android.net.CaptivePortalData.Builder setUserPortalUrl(@Nullable android.net.Uri, int);
-    method @NonNull public android.net.CaptivePortalData.Builder setVenueFriendlyName(@Nullable CharSequence);
-    method @NonNull public android.net.CaptivePortalData.Builder setVenueInfoUrl(@Nullable android.net.Uri);
-    method @NonNull public android.net.CaptivePortalData.Builder setVenueInfoUrl(@Nullable android.net.Uri, int);
-  }
-
-  public class ConnectivityManager {
-    method @NonNull @RequiresPermission(android.Manifest.permission.PACKET_KEEPALIVE_OFFLOAD) public android.net.SocketKeepalive createNattKeepalive(@NonNull android.net.Network, @NonNull android.os.ParcelFileDescriptor, @NonNull java.net.InetAddress, @NonNull java.net.InetAddress, @NonNull java.util.concurrent.Executor, @NonNull android.net.SocketKeepalive.Callback);
-    method @NonNull @RequiresPermission(android.Manifest.permission.PACKET_KEEPALIVE_OFFLOAD) public android.net.SocketKeepalive createSocketKeepalive(@NonNull android.net.Network, @NonNull java.net.Socket, @NonNull java.util.concurrent.Executor, @NonNull android.net.SocketKeepalive.Callback);
-    method @Deprecated @RequiresPermission(android.Manifest.permission.NETWORK_SETTINGS) public String getCaptivePortalServerUrl();
-    method @Deprecated @RequiresPermission(android.Manifest.permission.TETHER_PRIVILEGED) public void getLatestTetheringEntitlementResult(int, boolean, @NonNull java.util.concurrent.Executor, @NonNull android.net.ConnectivityManager.OnTetheringEntitlementResultListener);
-    method @Deprecated @RequiresPermission(anyOf={android.Manifest.permission.TETHER_PRIVILEGED, android.Manifest.permission.WRITE_SETTINGS}) public boolean isTetheringSupported();
-    method @RequiresPermission(anyOf={android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK, android.Manifest.permission.NETWORK_FACTORY}) public int registerNetworkProvider(@NonNull android.net.NetworkProvider);
-    method public void registerQosCallback(@NonNull android.net.QosSocketInfo, @NonNull java.util.concurrent.Executor, @NonNull android.net.QosCallback);
-    method @Deprecated @RequiresPermission(android.Manifest.permission.TETHER_PRIVILEGED) public void registerTetheringEventCallback(@NonNull java.util.concurrent.Executor, @NonNull android.net.ConnectivityManager.OnTetheringEventCallback);
-    method @RequiresPermission(android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK) public void requestNetwork(@NonNull android.net.NetworkRequest, int, int, @NonNull android.os.Handler, @NonNull android.net.ConnectivityManager.NetworkCallback);
-    method @RequiresPermission(anyOf={android.Manifest.permission.NETWORK_AIRPLANE_MODE, android.Manifest.permission.NETWORK_SETTINGS, android.Manifest.permission.NETWORK_SETUP_WIZARD, android.Manifest.permission.NETWORK_STACK}) public void setAirplaneMode(boolean);
-    method @RequiresPermission(android.Manifest.permission.CONTROL_OEM_PAID_NETWORK_PREFERENCE) public void setOemNetworkPreference(@NonNull android.net.OemNetworkPreferences, @Nullable java.util.concurrent.Executor, @Nullable Runnable);
-    method @RequiresPermission(anyOf={android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK, android.Manifest.permission.NETWORK_STACK}) public boolean shouldAvoidBadWifi();
-    method @RequiresPermission(android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK) public void startCaptivePortalApp(@NonNull android.net.Network, @NonNull android.os.Bundle);
-    method @Deprecated @RequiresPermission(android.Manifest.permission.TETHER_PRIVILEGED) public void startTethering(int, boolean, android.net.ConnectivityManager.OnStartTetheringCallback);
-    method @Deprecated @RequiresPermission(android.Manifest.permission.TETHER_PRIVILEGED) public void startTethering(int, boolean, android.net.ConnectivityManager.OnStartTetheringCallback, android.os.Handler);
-    method @Deprecated @RequiresPermission(android.Manifest.permission.TETHER_PRIVILEGED) public void stopTethering(int);
-    method @RequiresPermission(anyOf={android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK, android.Manifest.permission.NETWORK_FACTORY}) public void unregisterNetworkProvider(@NonNull android.net.NetworkProvider);
-    method public void unregisterQosCallback(@NonNull android.net.QosCallback);
-    method @Deprecated @RequiresPermission(android.Manifest.permission.TETHER_PRIVILEGED) public void unregisterTetheringEventCallback(@NonNull android.net.ConnectivityManager.OnTetheringEventCallback);
-    field public static final String EXTRA_CAPTIVE_PORTAL_PROBE_SPEC = "android.net.extra.CAPTIVE_PORTAL_PROBE_SPEC";
-    field public static final String EXTRA_CAPTIVE_PORTAL_USER_AGENT = "android.net.extra.CAPTIVE_PORTAL_USER_AGENT";
-    field public static final int TETHERING_BLUETOOTH = 2; // 0x2
-    field public static final int TETHERING_USB = 1; // 0x1
-    field public static final int TETHERING_WIFI = 0; // 0x0
-    field @Deprecated public static final int TETHER_ERROR_ENTITLEMENT_UNKONWN = 13; // 0xd
-    field @Deprecated public static final int TETHER_ERROR_NO_ERROR = 0; // 0x0
-    field @Deprecated public static final int TETHER_ERROR_PROVISION_FAILED = 11; // 0xb
-    field public static final int TYPE_NONE = -1; // 0xffffffff
-    field @Deprecated public static final int TYPE_PROXY = 16; // 0x10
-    field @Deprecated public static final int TYPE_WIFI_P2P = 13; // 0xd
-  }
-
-  @Deprecated public abstract static class ConnectivityManager.OnStartTetheringCallback {
-    ctor @Deprecated public ConnectivityManager.OnStartTetheringCallback();
-    method @Deprecated public void onTetheringFailed();
-    method @Deprecated public void onTetheringStarted();
-  }
-
-  @Deprecated public static interface ConnectivityManager.OnTetheringEntitlementResultListener {
-    method @Deprecated public void onTetheringEntitlementResult(int);
-  }
-
-  @Deprecated public abstract static class ConnectivityManager.OnTetheringEventCallback {
-    ctor @Deprecated public ConnectivityManager.OnTetheringEventCallback();
-    method @Deprecated public void onUpstreamChanged(@Nullable android.net.Network);
-  }
-
-  public final class DscpPolicy implements android.os.Parcelable {
-    method @Nullable public java.net.InetAddress getDestinationAddress();
-    method @Nullable public android.util.Range<java.lang.Integer> getDestinationPortRange();
-    method public int getDscpValue();
-    method public int getPolicyId();
-    method public int getProtocol();
-    method @Nullable public java.net.InetAddress getSourceAddress();
-    method public int getSourcePort();
-    field @NonNull public static final android.os.Parcelable.Creator<android.net.DscpPolicy> CREATOR;
-    field public static final int PROTOCOL_ANY = -1; // 0xffffffff
-    field public static final int SOURCE_PORT_ANY = -1; // 0xffffffff
-  }
-
-  public static final class DscpPolicy.Builder {
-    ctor public DscpPolicy.Builder(int, int);
-    method @NonNull public android.net.DscpPolicy build();
-    method @NonNull public android.net.DscpPolicy.Builder setDestinationAddress(@NonNull java.net.InetAddress);
-    method @NonNull public android.net.DscpPolicy.Builder setDestinationPortRange(@NonNull android.util.Range<java.lang.Integer>);
-    method @NonNull public android.net.DscpPolicy.Builder setProtocol(int);
-    method @NonNull public android.net.DscpPolicy.Builder setSourceAddress(@NonNull java.net.InetAddress);
-    method @NonNull public android.net.DscpPolicy.Builder setSourcePort(int);
-  }
-
-  public final class InvalidPacketException extends java.lang.Exception {
-    ctor public InvalidPacketException(int);
-    method public int getError();
-    field public static final int ERROR_INVALID_IP_ADDRESS = -21; // 0xffffffeb
-    field public static final int ERROR_INVALID_LENGTH = -23; // 0xffffffe9
-    field public static final int ERROR_INVALID_PORT = -22; // 0xffffffea
-  }
-
-  public final class IpConfiguration implements android.os.Parcelable {
-    ctor public IpConfiguration();
-    ctor public IpConfiguration(@NonNull android.net.IpConfiguration);
-    method @NonNull public android.net.IpConfiguration.IpAssignment getIpAssignment();
-    method @NonNull public android.net.IpConfiguration.ProxySettings getProxySettings();
-    method public void setHttpProxy(@Nullable android.net.ProxyInfo);
-    method public void setIpAssignment(@NonNull android.net.IpConfiguration.IpAssignment);
-    method public void setProxySettings(@NonNull android.net.IpConfiguration.ProxySettings);
-    method public void setStaticIpConfiguration(@Nullable android.net.StaticIpConfiguration);
-  }
-
-  public enum IpConfiguration.IpAssignment {
-    enum_constant public static final android.net.IpConfiguration.IpAssignment DHCP;
-    enum_constant public static final android.net.IpConfiguration.IpAssignment STATIC;
-    enum_constant public static final android.net.IpConfiguration.IpAssignment UNASSIGNED;
-  }
-
-  public enum IpConfiguration.ProxySettings {
-    enum_constant public static final android.net.IpConfiguration.ProxySettings NONE;
-    enum_constant public static final android.net.IpConfiguration.ProxySettings PAC;
-    enum_constant public static final android.net.IpConfiguration.ProxySettings STATIC;
-    enum_constant public static final android.net.IpConfiguration.ProxySettings UNASSIGNED;
-  }
-
-  public final class IpPrefix implements android.os.Parcelable {
-    ctor public IpPrefix(@NonNull String);
-  }
-
-  public class KeepalivePacketData {
-    ctor protected KeepalivePacketData(@NonNull java.net.InetAddress, @IntRange(from=0, to=65535) int, @NonNull java.net.InetAddress, @IntRange(from=0, to=65535) int, @NonNull byte[]) throws android.net.InvalidPacketException;
-    method @NonNull public java.net.InetAddress getDstAddress();
-    method public int getDstPort();
-    method @NonNull public byte[] getPacket();
-    method @NonNull public java.net.InetAddress getSrcAddress();
-    method public int getSrcPort();
-  }
-
-  public class LinkAddress implements android.os.Parcelable {
-    ctor public LinkAddress(@NonNull java.net.InetAddress, @IntRange(from=0, to=128) int, int, int);
-    ctor public LinkAddress(@NonNull java.net.InetAddress, @IntRange(from=0, to=128) int, int, int, long, long);
-    ctor public LinkAddress(@NonNull java.net.InetAddress, @IntRange(from=0, to=128) int);
-    ctor public LinkAddress(@NonNull String);
-    ctor public LinkAddress(@NonNull String, int, int);
-    method public long getDeprecationTime();
-    method public long getExpirationTime();
-    method public boolean isGlobalPreferred();
-    method public boolean isIpv4();
-    method public boolean isIpv6();
-    method public boolean isSameAddressAs(@Nullable android.net.LinkAddress);
-    field public static final long LIFETIME_PERMANENT = 9223372036854775807L; // 0x7fffffffffffffffL
-    field public static final long LIFETIME_UNKNOWN = -1L; // 0xffffffffffffffffL
-  }
-
-  public final class LinkProperties implements android.os.Parcelable {
-    ctor public LinkProperties(@Nullable android.net.LinkProperties);
-    ctor public LinkProperties(@Nullable android.net.LinkProperties, boolean);
-    method public boolean addDnsServer(@NonNull java.net.InetAddress);
-    method public boolean addLinkAddress(@NonNull android.net.LinkAddress);
-    method public boolean addPcscfServer(@NonNull java.net.InetAddress);
-    method @NonNull public java.util.List<java.net.InetAddress> getAddresses();
-    method @NonNull public java.util.List<java.lang.String> getAllInterfaceNames();
-    method @NonNull public java.util.List<android.net.LinkAddress> getAllLinkAddresses();
-    method @NonNull public java.util.List<android.net.RouteInfo> getAllRoutes();
-    method @Nullable public android.net.Uri getCaptivePortalApiUrl();
-    method @Nullable public android.net.CaptivePortalData getCaptivePortalData();
-    method @NonNull public java.util.List<java.net.InetAddress> getPcscfServers();
-    method @Nullable public String getTcpBufferSizes();
-    method @NonNull public java.util.List<java.net.InetAddress> getValidatedPrivateDnsServers();
-    method public boolean hasGlobalIpv6Address();
-    method public boolean hasIpv4Address();
-    method public boolean hasIpv4DefaultRoute();
-    method public boolean hasIpv4DnsServer();
-    method public boolean hasIpv6DefaultRoute();
-    method public boolean hasIpv6DnsServer();
-    method public boolean isIpv4Provisioned();
-    method public boolean isIpv6Provisioned();
-    method public boolean isProvisioned();
-    method public boolean isReachable(@NonNull java.net.InetAddress);
-    method public boolean removeDnsServer(@NonNull java.net.InetAddress);
-    method public boolean removeLinkAddress(@NonNull android.net.LinkAddress);
-    method public boolean removeRoute(@NonNull android.net.RouteInfo);
-    method public void setCaptivePortalApiUrl(@Nullable android.net.Uri);
-    method public void setCaptivePortalData(@Nullable android.net.CaptivePortalData);
-    method public void setPcscfServers(@NonNull java.util.Collection<java.net.InetAddress>);
-    method public void setPrivateDnsServerName(@Nullable String);
-    method public void setTcpBufferSizes(@Nullable String);
-    method public void setUsePrivateDns(boolean);
-    method public void setValidatedPrivateDnsServers(@NonNull java.util.Collection<java.net.InetAddress>);
-  }
-
-  public final class NattKeepalivePacketData extends android.net.KeepalivePacketData implements android.os.Parcelable {
-    ctor public NattKeepalivePacketData(@NonNull java.net.InetAddress, int, @NonNull java.net.InetAddress, int, @NonNull byte[]) throws android.net.InvalidPacketException;
-    method public int describeContents();
-    method public void writeToParcel(@NonNull android.os.Parcel, int);
-    field @NonNull public static final android.os.Parcelable.Creator<android.net.NattKeepalivePacketData> CREATOR;
-  }
-
-  public class Network implements android.os.Parcelable {
-    ctor public Network(@NonNull android.net.Network);
-    method public int getNetId();
-    method @NonNull public android.net.Network getPrivateDnsBypassingCopy();
-  }
-
-  public abstract class NetworkAgent {
-    ctor public NetworkAgent(@NonNull android.content.Context, @NonNull android.os.Looper, @NonNull String, @NonNull android.net.NetworkCapabilities, @NonNull android.net.LinkProperties, int, @NonNull android.net.NetworkAgentConfig, @Nullable android.net.NetworkProvider);
-    ctor public NetworkAgent(@NonNull android.content.Context, @NonNull android.os.Looper, @NonNull String, @NonNull android.net.NetworkCapabilities, @NonNull android.net.LinkProperties, @NonNull android.net.NetworkScore, @NonNull android.net.NetworkAgentConfig, @Nullable android.net.NetworkProvider);
-    method @Nullable public android.net.Network getNetwork();
-    method public void markConnected();
-    method public void onAddKeepalivePacketFilter(int, @NonNull android.net.KeepalivePacketData);
-    method public void onAutomaticReconnectDisabled();
-    method public void onBandwidthUpdateRequested();
-    method public void onDscpPolicyStatusUpdated(int, int);
-    method public void onNetworkCreated();
-    method public void onNetworkDestroyed();
-    method public void onNetworkUnwanted();
-    method public void onQosCallbackRegistered(int, @NonNull android.net.QosFilter);
-    method public void onQosCallbackUnregistered(int);
-    method public void onRemoveKeepalivePacketFilter(int);
-    method public void onSaveAcceptUnvalidated(boolean);
-    method public void onSignalStrengthThresholdsUpdated(@NonNull int[]);
-    method public void onStartSocketKeepalive(int, @NonNull java.time.Duration, @NonNull android.net.KeepalivePacketData);
-    method public void onStopSocketKeepalive(int);
-    method public void onValidationStatus(int, @Nullable android.net.Uri);
-    method @NonNull public android.net.Network register();
-    method public void sendAddDscpPolicy(@NonNull android.net.DscpPolicy);
-    method public void sendLinkProperties(@NonNull android.net.LinkProperties);
-    method public void sendNetworkCapabilities(@NonNull android.net.NetworkCapabilities);
-    method public void sendNetworkScore(@NonNull android.net.NetworkScore);
-    method public void sendNetworkScore(@IntRange(from=0, to=99) int);
-    method public final void sendQosCallbackError(int, int);
-    method public final void sendQosSessionAvailable(int, int, @NonNull android.net.QosSessionAttributes);
-    method public final void sendQosSessionLost(int, int, int);
-    method public void sendRemoveAllDscpPolicies();
-    method public void sendRemoveDscpPolicy(int);
-    method public final void sendSocketKeepaliveEvent(int, int);
-    method @Deprecated public void setLegacySubtype(int, @NonNull String);
-    method public void setLingerDuration(@NonNull java.time.Duration);
-    method public void setTeardownDelayMillis(@IntRange(from=0, to=0x1388) int);
-    method public void setUnderlyingNetworks(@Nullable java.util.List<android.net.Network>);
-    method public void unregister();
-    method public void unregisterAfterReplacement(@IntRange(from=0, to=0x1388) int);
-    field public static final int DSCP_POLICY_STATUS_DELETED = 4; // 0x4
-    field public static final int DSCP_POLICY_STATUS_INSUFFICIENT_PROCESSING_RESOURCES = 3; // 0x3
-    field public static final int DSCP_POLICY_STATUS_POLICY_NOT_FOUND = 5; // 0x5
-    field public static final int DSCP_POLICY_STATUS_REQUESTED_CLASSIFIER_NOT_SUPPORTED = 2; // 0x2
-    field public static final int DSCP_POLICY_STATUS_REQUEST_DECLINED = 1; // 0x1
-    field public static final int DSCP_POLICY_STATUS_SUCCESS = 0; // 0x0
-    field public static final int VALIDATION_STATUS_NOT_VALID = 2; // 0x2
-    field public static final int VALIDATION_STATUS_VALID = 1; // 0x1
-  }
-
-  public final class NetworkAgentConfig implements android.os.Parcelable {
-    method public int describeContents();
-    method public int getLegacyType();
-    method @NonNull public String getLegacyTypeName();
-    method public boolean isExplicitlySelected();
-    method public boolean isPartialConnectivityAcceptable();
-    method public boolean isUnvalidatedConnectivityAcceptable();
-    method public void writeToParcel(@NonNull android.os.Parcel, int);
-    field @NonNull public static final android.os.Parcelable.Creator<android.net.NetworkAgentConfig> CREATOR;
-  }
-
-  public static final class NetworkAgentConfig.Builder {
-    ctor public NetworkAgentConfig.Builder();
-    method @NonNull public android.net.NetworkAgentConfig build();
-    method @NonNull public android.net.NetworkAgentConfig.Builder setExplicitlySelected(boolean);
-    method @NonNull public android.net.NetworkAgentConfig.Builder setLegacyExtraInfo(@NonNull String);
-    method @NonNull public android.net.NetworkAgentConfig.Builder setLegacySubType(int);
-    method @NonNull public android.net.NetworkAgentConfig.Builder setLegacySubTypeName(@NonNull String);
-    method @NonNull public android.net.NetworkAgentConfig.Builder setLegacyType(int);
-    method @NonNull public android.net.NetworkAgentConfig.Builder setLegacyTypeName(@NonNull String);
-    method @NonNull public android.net.NetworkAgentConfig.Builder setNat64DetectionEnabled(boolean);
-    method @NonNull public android.net.NetworkAgentConfig.Builder setPartialConnectivityAcceptable(boolean);
-    method @NonNull public android.net.NetworkAgentConfig.Builder setProvisioningNotificationEnabled(boolean);
-    method @NonNull public android.net.NetworkAgentConfig.Builder setUnvalidatedConnectivityAcceptable(boolean);
-  }
-
-  public final class NetworkCapabilities implements android.os.Parcelable {
-    method @NonNull public int[] getAdministratorUids();
-    method @Nullable public static String getCapabilityCarrierName(int);
-    method @Nullable public String getSsid();
-    method @NonNull public java.util.Set<java.lang.Integer> getSubscriptionIds();
-    method @NonNull public int[] getTransportTypes();
-    method @Nullable public java.util.List<android.net.Network> getUnderlyingNetworks();
-    method public boolean isPrivateDnsBroken();
-    method public boolean satisfiedByNetworkCapabilities(@Nullable android.net.NetworkCapabilities);
-    field public static final int NET_CAPABILITY_BIP = 31; // 0x1f
-    field public static final int NET_CAPABILITY_NOT_VCN_MANAGED = 28; // 0x1c
-    field public static final int NET_CAPABILITY_OEM_PAID = 22; // 0x16
-    field public static final int NET_CAPABILITY_OEM_PRIVATE = 26; // 0x1a
-    field public static final int NET_CAPABILITY_PARTIAL_CONNECTIVITY = 24; // 0x18
-    field public static final int NET_CAPABILITY_VEHICLE_INTERNAL = 27; // 0x1b
-    field public static final int NET_CAPABILITY_VSIM = 30; // 0x1e
-  }
-
-  public static final class NetworkCapabilities.Builder {
-    ctor public NetworkCapabilities.Builder();
-    ctor public NetworkCapabilities.Builder(@NonNull android.net.NetworkCapabilities);
-    method @NonNull public android.net.NetworkCapabilities.Builder addCapability(int);
-    method @NonNull public android.net.NetworkCapabilities.Builder addEnterpriseId(int);
-    method @NonNull public android.net.NetworkCapabilities.Builder addTransportType(int);
-    method @NonNull public android.net.NetworkCapabilities build();
-    method @NonNull public android.net.NetworkCapabilities.Builder removeCapability(int);
-    method @NonNull public android.net.NetworkCapabilities.Builder removeEnterpriseId(int);
-    method @NonNull public android.net.NetworkCapabilities.Builder removeTransportType(int);
-    method @NonNull @RequiresPermission(android.Manifest.permission.NETWORK_FACTORY) public android.net.NetworkCapabilities.Builder setAdministratorUids(@NonNull int[]);
-    method @NonNull public android.net.NetworkCapabilities.Builder setLinkDownstreamBandwidthKbps(int);
-    method @NonNull public android.net.NetworkCapabilities.Builder setLinkUpstreamBandwidthKbps(int);
-    method @NonNull public android.net.NetworkCapabilities.Builder setNetworkSpecifier(@Nullable android.net.NetworkSpecifier);
-    method @NonNull @RequiresPermission(android.Manifest.permission.NETWORK_FACTORY) public android.net.NetworkCapabilities.Builder setOwnerUid(int);
-    method @NonNull @RequiresPermission(android.Manifest.permission.NETWORK_FACTORY) public android.net.NetworkCapabilities.Builder setRequestorPackageName(@Nullable String);
-    method @NonNull @RequiresPermission(android.Manifest.permission.NETWORK_FACTORY) public android.net.NetworkCapabilities.Builder setRequestorUid(int);
-    method @NonNull @RequiresPermission(android.Manifest.permission.NETWORK_SIGNAL_STRENGTH_WAKEUP) public android.net.NetworkCapabilities.Builder setSignalStrength(int);
-    method @NonNull @RequiresPermission(android.Manifest.permission.NETWORK_FACTORY) public android.net.NetworkCapabilities.Builder setSsid(@Nullable String);
-    method @NonNull public android.net.NetworkCapabilities.Builder setSubscriptionIds(@NonNull java.util.Set<java.lang.Integer>);
-    method @NonNull public android.net.NetworkCapabilities.Builder setTransportInfo(@Nullable android.net.TransportInfo);
-    method @NonNull @RequiresPermission(android.Manifest.permission.NETWORK_FACTORY) public android.net.NetworkCapabilities.Builder setUnderlyingNetworks(@Nullable java.util.List<android.net.Network>);
-    method @NonNull public static android.net.NetworkCapabilities.Builder withoutDefaultCapabilities();
-  }
-
-  public class NetworkProvider {
-    ctor public NetworkProvider(@NonNull android.content.Context, @NonNull android.os.Looper, @NonNull String);
-    method @RequiresPermission(android.Manifest.permission.NETWORK_FACTORY) public void declareNetworkRequestUnfulfillable(@NonNull android.net.NetworkRequest);
-    method public int getProviderId();
-    method public void onNetworkRequestWithdrawn(@NonNull android.net.NetworkRequest);
-    method public void onNetworkRequested(@NonNull android.net.NetworkRequest, @IntRange(from=0, to=99) int, int);
-    method @RequiresPermission(android.Manifest.permission.NETWORK_FACTORY) public void registerNetworkOffer(@NonNull android.net.NetworkScore, @NonNull android.net.NetworkCapabilities, @NonNull java.util.concurrent.Executor, @NonNull android.net.NetworkProvider.NetworkOfferCallback);
-    method @RequiresPermission(android.Manifest.permission.NETWORK_FACTORY) public void unregisterNetworkOffer(@NonNull android.net.NetworkProvider.NetworkOfferCallback);
-    field public static final int ID_NONE = -1; // 0xffffffff
-  }
-
-  public static interface NetworkProvider.NetworkOfferCallback {
-    method public void onNetworkNeeded(@NonNull android.net.NetworkRequest);
-    method public void onNetworkUnneeded(@NonNull android.net.NetworkRequest);
-  }
-
-  public class NetworkReleasedException extends java.lang.Exception {
-    ctor public NetworkReleasedException();
-  }
-
-  public class NetworkRequest implements android.os.Parcelable {
-    method @Nullable public String getRequestorPackageName();
-    method public int getRequestorUid();
-  }
-
-  public static class NetworkRequest.Builder {
-    method @NonNull @RequiresPermission(android.Manifest.permission.NETWORK_SIGNAL_STRENGTH_WAKEUP) public android.net.NetworkRequest.Builder setSignalStrength(int);
-    method @NonNull public android.net.NetworkRequest.Builder setSubscriptionIds(@NonNull java.util.Set<java.lang.Integer>);
-  }
-
-  public final class NetworkScore implements android.os.Parcelable {
-    method public int describeContents();
-    method public int getKeepConnectedReason();
-    method public int getLegacyInt();
-    method public boolean isExiting();
-    method public boolean isTransportPrimary();
-    method public void writeToParcel(@NonNull android.os.Parcel, int);
-    field @NonNull public static final android.os.Parcelable.Creator<android.net.NetworkScore> CREATOR;
-    field public static final int KEEP_CONNECTED_FOR_HANDOVER = 1; // 0x1
-    field public static final int KEEP_CONNECTED_NONE = 0; // 0x0
-  }
-
-  public static final class NetworkScore.Builder {
-    ctor public NetworkScore.Builder();
-    method @NonNull public android.net.NetworkScore build();
-    method @NonNull public android.net.NetworkScore.Builder setExiting(boolean);
-    method @NonNull public android.net.NetworkScore.Builder setKeepConnectedReason(int);
-    method @NonNull public android.net.NetworkScore.Builder setLegacyInt(int);
-    method @NonNull public android.net.NetworkScore.Builder setTransportPrimary(boolean);
-  }
-
-  public final class OemNetworkPreferences implements android.os.Parcelable {
-    method public int describeContents();
-    method @NonNull public java.util.Map<java.lang.String,java.lang.Integer> getNetworkPreferences();
-    method public void writeToParcel(@NonNull android.os.Parcel, int);
-    field @NonNull public static final android.os.Parcelable.Creator<android.net.OemNetworkPreferences> CREATOR;
-    field public static final int OEM_NETWORK_PREFERENCE_OEM_PAID = 1; // 0x1
-    field public static final int OEM_NETWORK_PREFERENCE_OEM_PAID_NO_FALLBACK = 2; // 0x2
-    field public static final int OEM_NETWORK_PREFERENCE_OEM_PAID_ONLY = 3; // 0x3
-    field public static final int OEM_NETWORK_PREFERENCE_OEM_PRIVATE_ONLY = 4; // 0x4
-    field public static final int OEM_NETWORK_PREFERENCE_UNINITIALIZED = 0; // 0x0
-  }
-
-  public static final class OemNetworkPreferences.Builder {
-    ctor public OemNetworkPreferences.Builder();
-    ctor public OemNetworkPreferences.Builder(@NonNull android.net.OemNetworkPreferences);
-    method @NonNull public android.net.OemNetworkPreferences.Builder addNetworkPreference(@NonNull String, int);
-    method @NonNull public android.net.OemNetworkPreferences build();
-    method @NonNull public android.net.OemNetworkPreferences.Builder clearNetworkPreference(@NonNull String);
-  }
-
-  public abstract class QosCallback {
-    ctor public QosCallback();
-    method public void onError(@NonNull android.net.QosCallbackException);
-    method public void onQosSessionAvailable(@NonNull android.net.QosSession, @NonNull android.net.QosSessionAttributes);
-    method public void onQosSessionLost(@NonNull android.net.QosSession);
-  }
-
-  public static class QosCallback.QosCallbackRegistrationException extends java.lang.RuntimeException {
-  }
-
-  public final class QosCallbackException extends java.lang.Exception {
-    ctor public QosCallbackException(@NonNull String);
-    ctor public QosCallbackException(@NonNull Throwable);
-  }
-
-  public abstract class QosFilter {
-    method @NonNull public abstract android.net.Network getNetwork();
-    method public abstract boolean matchesLocalAddress(@NonNull java.net.InetAddress, int, int);
-    method public boolean matchesProtocol(int);
-    method public abstract boolean matchesRemoteAddress(@NonNull java.net.InetAddress, int, int);
-  }
-
-  public final class QosSession implements android.os.Parcelable {
-    ctor public QosSession(int, int);
-    method public int describeContents();
-    method public int getSessionId();
-    method public int getSessionType();
-    method public long getUniqueId();
-    method public void writeToParcel(@NonNull android.os.Parcel, int);
-    field @NonNull public static final android.os.Parcelable.Creator<android.net.QosSession> CREATOR;
-    field public static final int TYPE_EPS_BEARER = 1; // 0x1
-    field public static final int TYPE_NR_BEARER = 2; // 0x2
-  }
-
-  public interface QosSessionAttributes {
-  }
-
-  public final class QosSocketInfo implements android.os.Parcelable {
-    ctor public QosSocketInfo(@NonNull android.net.Network, @NonNull java.net.Socket) throws java.io.IOException;
-    ctor public QosSocketInfo(@NonNull android.net.Network, @NonNull java.net.DatagramSocket) throws java.io.IOException;
-    method public int describeContents();
-    method @NonNull public java.net.InetSocketAddress getLocalSocketAddress();
-    method @NonNull public android.net.Network getNetwork();
-    method @Nullable public java.net.InetSocketAddress getRemoteSocketAddress();
-    method public void writeToParcel(@NonNull android.os.Parcel, int);
-    field @NonNull public static final android.os.Parcelable.Creator<android.net.QosSocketInfo> CREATOR;
-  }
-
-  public final class RouteInfo implements android.os.Parcelable {
-    ctor public RouteInfo(@Nullable android.net.IpPrefix, @Nullable java.net.InetAddress, @Nullable String, int);
-    ctor public RouteInfo(@Nullable android.net.IpPrefix, @Nullable java.net.InetAddress, @Nullable String, int, int);
-    method public int getMtu();
-  }
-
-  public abstract class SocketKeepalive implements java.lang.AutoCloseable {
-    method public final void start(@IntRange(from=0xa, to=0xe10) int, int, @Nullable android.net.Network);
-    field public static final int ERROR_NO_SUCH_SLOT = -33; // 0xffffffdf
-    field public static final int FLAG_AUTOMATIC_ON_OFF = 1; // 0x1
-    field public static final int SUCCESS = 0; // 0x0
-  }
-
-  public class SocketLocalAddressChangedException extends java.lang.Exception {
-    ctor public SocketLocalAddressChangedException();
-  }
-
-  public class SocketNotBoundException extends java.lang.Exception {
-    ctor public SocketNotBoundException();
-  }
-
-  public class SocketNotConnectedException extends java.lang.Exception {
-    ctor public SocketNotConnectedException();
-  }
-
-  public class SocketRemoteAddressChangedException extends java.lang.Exception {
-    ctor public SocketRemoteAddressChangedException();
-  }
-
-  public final class StaticIpConfiguration implements android.os.Parcelable {
-    ctor public StaticIpConfiguration();
-    ctor public StaticIpConfiguration(@Nullable android.net.StaticIpConfiguration);
-    method public void addDnsServer(@NonNull java.net.InetAddress);
-    method public void clear();
-    method @NonNull public java.util.List<android.net.RouteInfo> getRoutes(@Nullable String);
-  }
-
-  public final class TcpKeepalivePacketData extends android.net.KeepalivePacketData implements android.os.Parcelable {
-    ctor public TcpKeepalivePacketData(@NonNull java.net.InetAddress, int, @NonNull java.net.InetAddress, int, @NonNull byte[], int, int, int, int, int, int) throws android.net.InvalidPacketException;
-    method public int describeContents();
-    method public int getIpTos();
-    method public int getIpTtl();
-    method public int getTcpAck();
-    method public int getTcpSeq();
-    method public int getTcpWindow();
-    method public int getTcpWindowScale();
-    method public void writeToParcel(@NonNull android.os.Parcel, int);
-    field @NonNull public static final android.os.Parcelable.Creator<android.net.TcpKeepalivePacketData> CREATOR;
-  }
-
-  public final class VpnTransportInfo implements android.os.Parcelable android.net.TransportInfo {
-    ctor public VpnTransportInfo(int, @Nullable String, boolean, boolean);
-    method public boolean areLongLivedTcpConnectionsExpensive();
-    method public int describeContents();
-    method public int getType();
-    method public boolean isBypassable();
-    method public void writeToParcel(@NonNull android.os.Parcel, int);
-    field @NonNull public static final android.os.Parcelable.Creator<android.net.VpnTransportInfo> CREATOR;
-  }
-
-}
-
-package android.net.apf {
-
-  public final class ApfCapabilities implements android.os.Parcelable {
-    ctor public ApfCapabilities(int, int, int);
-    method public int describeContents();
-    method public static boolean getApfDrop8023Frames();
-    method @NonNull public static int[] getApfEtherTypeBlackList();
-    method public boolean hasDataAccess();
-    method public void writeToParcel(android.os.Parcel, int);
-    field public static final android.os.Parcelable.Creator<android.net.apf.ApfCapabilities> CREATOR;
-    field public final int apfPacketFormat;
-    field public final int apfVersionSupported;
-    field public final int maximumApfProgramSize;
-  }
-
-}
-
diff --git a/framework/cronet_disabled/api/system-lint-baseline.txt b/framework/cronet_disabled/api/system-lint-baseline.txt
deleted file mode 100644
index 9a97707..0000000
--- a/framework/cronet_disabled/api/system-lint-baseline.txt
+++ /dev/null
@@ -1 +0,0 @@
-// Baseline format: 1.0
diff --git a/framework/cronet_disabled/api/system-removed.txt b/framework/cronet_disabled/api/system-removed.txt
deleted file mode 100644
index d802177..0000000
--- a/framework/cronet_disabled/api/system-removed.txt
+++ /dev/null
@@ -1 +0,0 @@
-// Signature format: 2.0
diff --git a/framework/jarjar-excludes.txt b/framework/jarjar-excludes.txt
index 1ac5e8e..bd513d2 100644
--- a/framework/jarjar-excludes.txt
+++ b/framework/jarjar-excludes.txt
@@ -14,6 +14,15 @@
 # TODO: move files to android.net.connectivity.visiblefortesting
 android\.net\.IConnectivityDiagnosticsCallback(\$.+)?
 
+# Classes used by tethering as a hidden API are compiled as a lib in target
+# connectivity-internal-api-util. Because it's used by tethering, it can't
+# be jarjared. Classes in android.net.connectivity are exempt from being
+# listed here because they are already in the target package and as such
+# are already not jarjared.
+# Because Tethering can be installed on R without Connectivity, any use
+# of these classes must be protected by a check for >= S SDK.
+# It's unlikely anybody else declares a hidden class with this name ?
+android\.net\.RoutingCoordinatorManager(\$.+)?
 
 # KeepaliveUtils is used by ConnectivityManager CTS
 # TODO: move into service-connectivity so framework-connectivity stops using
@@ -28,9 +37,3 @@
 # This is required since android.net.http contains api classes and hidden classes.
 # TODO: Remove this after hidden classes are moved to different package
 android\.net\.http\..+
-
-# TODO: OffloadServiceInfo is being added as an API, but wasn't an API yet in the first module
-# versions targeting U. Do not jarjar it such versions so that tests do not have to cover both
-# cases. This will be removed in an upcoming change marking it as API.
-android\.net\.nsd\.OffloadServiceInfo(\$.+)?
-android\.net\.nsd\.OffloadEngine(\$.+)?
diff --git a/framework/lint-baseline.xml b/framework/lint-baseline.xml
new file mode 100644
index 0000000..f68aad7
--- /dev/null
+++ b/framework/lint-baseline.xml
@@ -0,0 +1,367 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<issues format="6" by="lint 8.0.0-dev" type="baseline" dependencies="true" variant="all" version="8.0.0-dev">
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `android.net.IpSecManager.UdpEncapsulationSocket#getResourceId`"
+        errorLine1="        return new NattSocketKeepalive(mService, network, dup, socket.getResourceId(), source,"
+        errorLine2="                                                                      ~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework/src/android/net/ConnectivityManager.java"
+            line="2456"
+            column="71"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `android.net.Proxy#setHttpProxyConfiguration`"
+        errorLine1="                Proxy.setHttpProxyConfiguration(getInstance().getDefaultProxy());"
+        errorLine2="                      ~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework/src/android/net/ConnectivityManager.java"
+            line="5323"
+            column="23"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `android.os.Build#isDebuggable`"
+        errorLine1="            if (!Build.isDebuggable()) {"
+        errorLine2="                       ~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework/src/android/net/ConnectivitySettingsManager.java"
+            line="1072"
+            column="24"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `android.os.UserHandle#getUid`"
+        errorLine1="        final int end = nextUser.getUid(0 /* appId */) - 1;"
+        errorLine2="                                 ~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework/src/android/net/UidRange.java"
+            line="50"
+            column="34"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `android.os.UserHandle#getUid`"
+        errorLine1="        final int start = user.getUid(0 /* appId */);"
+        errorLine2="                               ~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework/src/android/net/UidRange.java"
+            line="49"
+            column="32"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `android.provider.Settings#checkAndNoteWriteSettingsOperation`"
+        errorLine1="        return Settings.checkAndNoteWriteSettingsOperation(context, uid, callingPackage,"
+        errorLine2="                        ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework/src/android/net/ConnectivityManager.java"
+            line="2799"
+            column="25"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `java.net.InetAddress#clearDnsCache`"
+        errorLine1="            InetAddress.clearDnsCache();"
+        errorLine2="                        ~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework/src/android/net/ConnectivityManager.java"
+            line="5329"
+            column="25"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `java.net.InetAddress#getAllByNameOnNet`"
+        errorLine1="        return InetAddress.getAllByNameOnNet(host, getNetIdForResolv());"
+        errorLine2="                           ~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework/src/android/net/Network.java"
+            line="145"
+            column="28"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `java.net.InetAddress#getByNameOnNet`"
+        errorLine1="        return InetAddress.getByNameOnNet(host, getNetIdForResolv());"
+        errorLine2="                           ~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework/src/android/net/Network.java"
+            line="158"
+            column="28"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `libcore.io.IoUtils#closeQuietly`"
+        errorLine1="                        IoUtils.closeQuietly(is);"
+        errorLine2="                                ~~~~~~~~~~~~">
+        <location
+            file="frameworks/base/core/java/com/android/internal/util/FileRotator.java"
+            line="168"
+            column="33"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `libcore.io.IoUtils#closeQuietly`"
+        errorLine1="                        if (failed) IoUtils.closeQuietly(socket);"
+        errorLine2="                                            ~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework/src/android/net/Network.java"
+            line="216"
+            column="45"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `libcore.io.IoUtils#closeQuietly`"
+        errorLine1="                if (failed) IoUtils.closeQuietly(socket);"
+        errorLine2="                                    ~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework/src/android/net/Network.java"
+            line="241"
+            column="37"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `libcore.io.IoUtils#closeQuietly`"
+        errorLine1="                if (failed) IoUtils.closeQuietly(socket);"
+        errorLine2="                                    ~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework/src/android/net/Network.java"
+            line="254"
+            column="37"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `libcore.io.IoUtils#closeQuietly`"
+        errorLine1="                if (failed) IoUtils.closeQuietly(socket);"
+        errorLine2="                                    ~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework/src/android/net/Network.java"
+            line="272"
+            column="37"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `libcore.io.IoUtils#closeQuietly`"
+        errorLine1="            IoUtils.closeQuietly(bis);"
+        errorLine2="                    ~~~~~~~~~~~~">
+        <location
+            file="frameworks/base/core/java/com/android/internal/util/FileRotator.java"
+            line="391"
+            column="21"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `libcore.io.IoUtils#closeQuietly`"
+        errorLine1="            IoUtils.closeQuietly(bos);"
+        errorLine2="                    ~~~~~~~~~~~~">
+        <location
+            file="frameworks/base/core/java/com/android/internal/util/FileRotator.java"
+            line="406"
+            column="21"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `libcore.io.IoUtils#closeQuietly`"
+        errorLine1="            IoUtils.closeQuietly(socket);"
+        errorLine2="                    ~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework/src/android/net/util/DnsUtils.java"
+            line="181"
+            column="21"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `libcore.io.IoUtils#closeQuietly`"
+        errorLine1="            IoUtils.closeQuietly(socket);"
+        errorLine2="                    ~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework/src/android/net/util/DnsUtils.java"
+            line="373"
+            column="21"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `libcore.io.IoUtils#closeQuietly`"
+        errorLine1="            IoUtils.closeQuietly(zos);"
+        errorLine2="                    ~~~~~~~~~~~~">
+        <location
+            file="frameworks/base/core/java/com/android/internal/util/FileRotator.java"
+            line="175"
+            column="21"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `libcore.net.InetAddressUtils#isNumericAddress`"
+        errorLine1="        return InetAddressUtils.isNumericAddress(address);"
+        errorLine2="                                ~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework/src/android/net/InetAddresses.java"
+            line="46"
+            column="33"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `libcore.net.InetAddressUtils#parseNumericAddress`"
+        errorLine1="        return InetAddressUtils.parseNumericAddress(address);"
+        errorLine2="                                ~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework/src/android/net/InetAddresses.java"
+            line="63"
+            column="33"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `libcore.net.event.NetworkEventDispatcher#dispatchNetworkConfigurationChange`"
+        errorLine1="            NetworkEventDispatcher.getInstance().dispatchNetworkConfigurationChange();"
+        errorLine2="                                                 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework/src/android/net/ConnectivityManager.java"
+            line="5332"
+            column="50"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `libcore.net.event.NetworkEventDispatcher#getInstance`"
+        errorLine1="            NetworkEventDispatcher.getInstance().dispatchNetworkConfigurationChange();"
+        errorLine2="                                   ~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework/src/android/net/ConnectivityManager.java"
+            line="5332"
+            column="36"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `libcore.net.http.HttpURLConnectionFactory#createInstance`"
+        errorLine1="        HttpURLConnectionFactory urlConnectionFactory = HttpURLConnectionFactory.createInstance();"
+        errorLine2="                                                                                 ~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework/src/android/net/Network.java"
+            line="302"
+            column="82"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `libcore.net.http.HttpURLConnectionFactory#openConnection`"
+        errorLine1="        return urlConnectionFactory.openConnection(url, socketFactory, proxy);"
+        errorLine2="                                    ~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework/src/android/net/Network.java"
+            line="372"
+            column="37"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `libcore.net.http.HttpURLConnectionFactory#setDns`"
+        errorLine1="        urlConnectionFactory.setDns(dnsLookup); // Let traffic go via dnsLookup"
+        errorLine2="                             ~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework/src/android/net/Network.java"
+            line="303"
+            column="30"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `libcore.net.http.HttpURLConnectionFactory#setNewConnectionPool`"
+        errorLine1="        urlConnectionFactory.setNewConnectionPool(httpMaxConnections,"
+        errorLine2="                             ~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework/src/android/net/Network.java"
+            line="305"
+            column="30"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `new android.net.EthernetNetworkSpecifier`"
+        errorLine1="                    return setNetworkSpecifier(new EthernetNetworkSpecifier(networkSpecifier));"
+        errorLine2="                                               ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework/src/android/net/NetworkRequest.java"
+            line="525"
+            column="48"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Cast from `EthernetNetworkSpecifier` to `NetworkSpecifier` requires API level 31 (current min is 30)"
+        errorLine1="                    return setNetworkSpecifier(new EthernetNetworkSpecifier(networkSpecifier));"
+        errorLine2="                                               ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework/src/android/net/NetworkRequest.java"
+            line="525"
+            column="48"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Class requires API level 31 (current min is 30): `android.telephony.data.EpsBearerQosSessionAttributes`"
+        errorLine1="                    (EpsBearerQosSessionAttributes)attributes));"
+        errorLine2="                     ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework/src/android/net/NetworkAgent.java"
+            line="1421"
+            column="22"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Class requires API level 31 (current min is 30): `android.telephony.data.EpsBearerQosSessionAttributes`"
+        errorLine1="        if (attributes instanceof EpsBearerQosSessionAttributes) {"
+        errorLine2="                                  ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework/src/android/net/NetworkAgent.java"
+            line="1418"
+            column="35"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Class requires API level 31 (current min is 30): `android.telephony.data.NrQosSessionAttributes`"
+        errorLine1="                    (NrQosSessionAttributes)attributes));"
+        errorLine2="                     ~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework/src/android/net/NetworkAgent.java"
+            line="1425"
+            column="22"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Class requires API level 31 (current min is 30): `android.telephony.data.NrQosSessionAttributes`"
+        errorLine1="        } else if (attributes instanceof NrQosSessionAttributes) {"
+        errorLine2="                                         ~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework/src/android/net/NetworkAgent.java"
+            line="1422"
+            column="42"/>
+    </issue>
+
+</issues>
\ No newline at end of file
diff --git a/framework/src/android/net/BpfNetMapsConstants.java b/framework/src/android/net/BpfNetMapsConstants.java
index 2191682..c784597 100644
--- a/framework/src/android/net/BpfNetMapsConstants.java
+++ b/framework/src/android/net/BpfNetMapsConstants.java
@@ -16,6 +16,15 @@
 
 package android.net;
 
+import static android.net.ConnectivityManager.FIREWALL_CHAIN_DOZABLE;
+import static android.net.ConnectivityManager.FIREWALL_CHAIN_LOW_POWER_STANDBY;
+import static android.net.ConnectivityManager.FIREWALL_CHAIN_OEM_DENY_1;
+import static android.net.ConnectivityManager.FIREWALL_CHAIN_OEM_DENY_2;
+import static android.net.ConnectivityManager.FIREWALL_CHAIN_OEM_DENY_3;
+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 android.util.Pair;
 
 import com.android.net.module.util.Struct;
@@ -43,8 +52,16 @@
             "/sys/fs/bpf/netd_shared/map_netd_uid_permission_map";
     public static final String COOKIE_TAG_MAP_PATH =
             "/sys/fs/bpf/netd_shared/map_netd_cookie_tag_map";
+    public static final String DATA_SAVER_ENABLED_MAP_PATH =
+            "/sys/fs/bpf/netd_shared/map_netd_data_saver_enabled_map";
+    public static final String INGRESS_DISCARD_MAP_PATH =
+            "/sys/fs/bpf/netd_shared/map_netd_ingress_discard_map";
     public static final Struct.S32 UID_RULES_CONFIGURATION_KEY = new Struct.S32(0);
     public static final Struct.S32 CURRENT_STATS_MAP_CONFIGURATION_KEY = new Struct.S32(1);
+    public static final Struct.S32 DATA_SAVER_ENABLED_KEY = new Struct.S32(0);
+
+    public static final short DATA_SAVER_DISABLED = 0;
+    public static final short DATA_SAVER_ENABLED = 1;
 
     // LINT.IfChange(match_type)
     public static final long NO_MATCH = 0;
@@ -60,7 +77,6 @@
     public static final long OEM_DENY_1_MATCH = (1 << 9);
     public static final long OEM_DENY_2_MATCH = (1 << 10);
     public static final long OEM_DENY_3_MATCH = (1 << 11);
-    // LINT.ThenChange(packages/modules/Connectivity/bpf_progs/netd.h)
 
     public static final List<Pair<Long, String>> MATCH_LIST = Arrays.asList(
             Pair.create(HAPPY_BOX_MATCH, "HAPPY_BOX_MATCH"),
@@ -76,4 +92,29 @@
             Pair.create(OEM_DENY_2_MATCH, "OEM_DENY_2_MATCH"),
             Pair.create(OEM_DENY_3_MATCH, "OEM_DENY_3_MATCH")
     );
+
+    /**
+     * List of all firewall allow chains.
+     *
+     * Allow chains mean the firewall denies all uids by default, uids must be explicitly allowed.
+     */
+    public static final List<Integer> ALLOW_CHAINS = List.of(
+            FIREWALL_CHAIN_DOZABLE,
+            FIREWALL_CHAIN_POWERSAVE,
+            FIREWALL_CHAIN_RESTRICTED,
+            FIREWALL_CHAIN_LOW_POWER_STANDBY
+    );
+
+    /**
+     * List of all firewall deny chains.
+     *
+     * Deny chains mean the firewall allows all uids by default, uids must be explicitly denied.
+     */
+    public static final List<Integer> DENY_CHAINS = List.of(
+            FIREWALL_CHAIN_STANDBY,
+            FIREWALL_CHAIN_OEM_DENY_1,
+            FIREWALL_CHAIN_OEM_DENY_2,
+            FIREWALL_CHAIN_OEM_DENY_3
+    );
+    // LINT.ThenChange(../../../../bpf_progs/netd.h)
 }
diff --git a/framework/src/android/net/BpfNetMapsReader.java b/framework/src/android/net/BpfNetMapsReader.java
new file mode 100644
index 0000000..37c58f0
--- /dev/null
+++ b/framework/src/android/net/BpfNetMapsReader.java
@@ -0,0 +1,252 @@
+/*
+ * 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.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;
+import com.android.net.module.util.Struct.S32;
+import com.android.net.module.util.Struct.U32;
+
+/**
+ * A helper class to *read* java BpfMaps.
+ * @hide
+ */
+@RequiresApi(Build.VERSION_CODES.TIRAMISU)  // BPF maps were only mainlined in T
+public class BpfNetMapsReader {
+    // 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 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();
+    }
+
+    /**
+     * 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 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<Struct.S32, Struct.U32> configurationMap, final int chain) {
+        throwIfPreT("isChainEnabled is not available on pre-T devices");
+
+        final long match = getMatchByFirewallChain(chain);
+        try {
+            final Struct.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<Struct.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 Struct.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;
+    }
+}
diff --git a/framework/src/android/net/BpfNetMapsUtils.java b/framework/src/android/net/BpfNetMapsUtils.java
index d464e3d..e9c9137 100644
--- a/framework/src/android/net/BpfNetMapsUtils.java
+++ b/framework/src/android/net/BpfNetMapsUtils.java
@@ -16,6 +16,8 @@
 
 package android.net;
 
+import static android.net.BpfNetMapsConstants.ALLOW_CHAINS;
+import static android.net.BpfNetMapsConstants.DENY_CHAINS;
 import static android.net.BpfNetMapsConstants.DOZABLE_MATCH;
 import static android.net.BpfNetMapsConstants.LOW_POWER_STANDBY_MATCH;
 import static android.net.BpfNetMapsConstants.MATCH_LIST;
@@ -39,6 +41,8 @@
 import android.os.ServiceSpecificException;
 import android.util.Pair;
 
+import com.android.modules.utils.build.SdkLevel;
+
 import java.util.StringJoiner;
 
 /**
@@ -80,26 +84,18 @@
     }
 
     /**
-     * Get if the chain is allow list or not.
+     * Get whether the chain is an allow-list or a deny-list.
      *
      * ALLOWLIST means the firewall denies all by default, uids must be explicitly allowed
-     * DENYLIST means the firewall allows all by default, uids must be explicitly denyed
+     * DENYLIST means the firewall allows all by default, uids must be explicitly denied
      */
     public static boolean isFirewallAllowList(final int chain) {
-        switch (chain) {
-            case FIREWALL_CHAIN_DOZABLE:
-            case FIREWALL_CHAIN_POWERSAVE:
-            case FIREWALL_CHAIN_RESTRICTED:
-            case FIREWALL_CHAIN_LOW_POWER_STANDBY:
-                return true;
-            case FIREWALL_CHAIN_STANDBY:
-            case FIREWALL_CHAIN_OEM_DENY_1:
-            case FIREWALL_CHAIN_OEM_DENY_2:
-            case FIREWALL_CHAIN_OEM_DENY_3:
-                return false;
-            default:
-                throw new ServiceSpecificException(EINVAL, "Invalid firewall chain: " + chain);
+        if (ALLOW_CHAINS.contains(chain)) {
+            return true;
+        } else if (DENY_CHAINS.contains(chain)) {
+            return false;
         }
+        throw new ServiceSpecificException(EINVAL, "Invalid firewall chain: " + chain);
     }
 
     /**
@@ -124,4 +120,15 @@
         }
         return sj.toString();
     }
+
+    public static final boolean PRE_T = !SdkLevel.isAtLeastT();
+
+    /**
+     * Throw UnsupportedOperationException if SdkLevel is before T.
+     */
+    public static void throwIfPreT(final String msg) {
+        if (PRE_T) {
+            throw new UnsupportedOperationException(msg);
+        }
+    }
 }
diff --git a/framework/src/android/net/ConnectivityManager.java b/framework/src/android/net/ConnectivityManager.java
index 2315521..eb8f8c3 100644
--- a/framework/src/android/net/ConnectivityManager.java
+++ b/framework/src/android/net/ConnectivityManager.java
@@ -16,6 +16,8 @@
 package android.net;
 
 import static android.annotation.SystemApi.Client.MODULE_LIBRARIES;
+import static android.content.pm.ApplicationInfo.FLAG_PERSISTENT;
+import static android.content.pm.ApplicationInfo.FLAG_SYSTEM;
 import static android.net.NetworkCapabilities.NET_ENTERPRISE_ID_1;
 import static android.net.NetworkRequest.Type.BACKGROUND_REQUEST;
 import static android.net.NetworkRequest.Type.LISTEN;
@@ -25,22 +27,30 @@
 import static android.net.NetworkRequest.Type.TRACK_SYSTEM_DEFAULT;
 import static android.net.QosCallback.QosCallbackRegistrationException;
 
+import static com.android.internal.annotations.VisibleForTesting.Visibility.PRIVATE;
+
 import android.annotation.CallbackExecutor;
+import android.annotation.FlaggedApi;
 import android.annotation.IntDef;
 import android.annotation.NonNull;
 import android.annotation.Nullable;
+import android.annotation.RequiresApi;
 import android.annotation.RequiresPermission;
 import android.annotation.SdkConstant;
 import android.annotation.SdkConstant.SdkConstantType;
 import android.annotation.SuppressLint;
 import android.annotation.SystemApi;
 import android.annotation.SystemService;
+import android.annotation.TargetApi;
+import android.app.Application;
 import android.app.PendingIntent;
 import android.app.admin.DevicePolicyManager;
 import android.compat.annotation.UnsupportedAppUsage;
+import android.content.BroadcastReceiver;
 import android.content.ComponentName;
 import android.content.Context;
 import android.content.Intent;
+import android.content.IntentFilter;
 import android.net.ConnectivityDiagnosticsManager.DataStallReport.DetectionMethod;
 import android.net.IpSecManager.UdpEncapsulationSocket;
 import android.net.SocketKeepalive.Callback;
@@ -72,6 +82,7 @@
 import android.util.SparseIntArray;
 
 import com.android.internal.annotations.GuardedBy;
+import com.android.internal.annotations.VisibleForTesting;
 
 import libcore.net.event.NetworkEventDispatcher;
 
@@ -93,6 +104,7 @@
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.atomic.AtomicBoolean;
 
 /**
  * Class that answers queries about the state of network connectivity. It also
@@ -115,6 +127,16 @@
     private static final String TAG = "ConnectivityManager";
     private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);
 
+    // TODO : remove this class when udc-mainline-prod is abandoned and android.net.flags.Flags is
+    // available here
+    /** @hide */
+    public static class Flags {
+        static final String SET_DATA_SAVER_VIA_CM =
+                "com.android.net.flags.set_data_saver_via_cm";
+        static final String SUPPORT_IS_UID_NETWORKING_BLOCKED =
+                "com.android.net.flags.support_is_uid_networking_blocked";
+    }
+
     /**
      * A change in network connectivity has occurred. A default connection has either
      * been established or lost. The NetworkInfo for the affected network is
@@ -3811,11 +3833,28 @@
     @RequiresPermission(anyOf = {
             NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK,
             android.Manifest.permission.NETWORK_FACTORY})
-    public Network registerNetworkAgent(INetworkAgent na, NetworkInfo ni, LinkProperties lp,
-            NetworkCapabilities nc, @NonNull NetworkScore score, NetworkAgentConfig config,
-            int providerId) {
+    public Network registerNetworkAgent(@NonNull INetworkAgent na, @NonNull NetworkInfo ni,
+            @NonNull LinkProperties lp, @NonNull NetworkCapabilities nc,
+            @NonNull NetworkScore score, @NonNull NetworkAgentConfig config, int providerId) {
+        return registerNetworkAgent(na, ni, lp, nc, null /* localNetworkConfig */, score, config,
+                providerId);
+    }
+
+    /**
+     * @hide
+     * Register a NetworkAgent with ConnectivityService.
+     * @return Network corresponding to NetworkAgent.
+     */
+    @RequiresPermission(anyOf = {
+            NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK,
+            android.Manifest.permission.NETWORK_FACTORY})
+    public Network registerNetworkAgent(@NonNull INetworkAgent na, @NonNull NetworkInfo ni,
+            @NonNull LinkProperties lp, @NonNull NetworkCapabilities nc,
+            @Nullable LocalNetworkConfig localNetworkConfig, @NonNull NetworkScore score,
+            @NonNull NetworkAgentConfig config, int providerId) {
         try {
-            return mService.registerNetworkAgent(na, ni, lp, nc, score, config, providerId);
+            return mService.registerNetworkAgent(na, ni, lp, nc, score, localNetworkConfig, config,
+                    providerId);
         } catch (RemoteException e) {
             throw e.rethrowFromSystemServer();
         }
@@ -5941,6 +5980,28 @@
     }
 
     /**
+     * Sets data saver switch.
+     *
+     * @param enable True if enable.
+     * @throws IllegalStateException if failed.
+     * @hide
+     */
+    @FlaggedApi(Flags.SET_DATA_SAVER_VIA_CM)
+    @SystemApi(client = MODULE_LIBRARIES)
+    @RequiresPermission(anyOf = {
+            android.Manifest.permission.NETWORK_SETTINGS,
+            android.Manifest.permission.NETWORK_STACK,
+            NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK
+    })
+    public void setDataSaverEnabled(final boolean enable) {
+        try {
+            mService.setDataSaverEnabled(enable);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
      * Adds the specified UID to the list of UIds that are allowed to use data on metered networks
      * even when background data is restricted. The deny list takes precedence over the allow list.
      *
@@ -6149,6 +6210,125 @@
         }
     }
 
+    /**
+     * Helper class to track data saver status.
+     *
+     * The class will fetch current data saver status from {@link NetworkPolicyManager} when
+     * initialized, and listening for status changed intent to cache the latest status.
+     *
+     * @hide
+     */
+    @TargetApi(Build.VERSION_CODES.TIRAMISU) // RECEIVER_NOT_EXPORTED requires T.
+    @VisibleForTesting(visibility = PRIVATE)
+    public static class DataSaverStatusTracker extends BroadcastReceiver {
+        private static final Object sDataSaverStatusTrackerLock = new Object();
+
+        private static volatile DataSaverStatusTracker sInstance;
+
+        /**
+         * Gets a static instance of the class.
+         *
+         * @param context A {@link Context} for initialization. Note that since the data saver
+         *                status is global on a device, passing any context is equivalent.
+         * @return The static instance of a {@link DataSaverStatusTracker}.
+         */
+        public static DataSaverStatusTracker getInstance(@NonNull Context context) {
+            if (sInstance == null) {
+                synchronized (sDataSaverStatusTrackerLock) {
+                    if (sInstance == null) {
+                        sInstance = new DataSaverStatusTracker(context);
+                    }
+                }
+            }
+            return sInstance;
+        }
+
+        private final NetworkPolicyManager mNpm;
+        // The value updates on the caller's binder thread or UI thread.
+        private final AtomicBoolean mIsDataSaverEnabled;
+
+        @VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE)
+        public DataSaverStatusTracker(final Context context) {
+            // To avoid leaks, take the application context.
+            final Context appContext;
+            if (context instanceof Application) {
+                appContext = context;
+            } else {
+                appContext = context.getApplicationContext();
+            }
+
+            if ((appContext.getApplicationInfo().flags & FLAG_PERSISTENT) == 0
+                    && (appContext.getApplicationInfo().flags & FLAG_SYSTEM) == 0) {
+                throw new IllegalStateException("Unexpected caller: "
+                        + appContext.getApplicationInfo().packageName);
+            }
+
+            mNpm = appContext.getSystemService(NetworkPolicyManager.class);
+            final IntentFilter filter = new IntentFilter(
+                    ConnectivityManager.ACTION_RESTRICT_BACKGROUND_CHANGED);
+            // The receiver should not receive broadcasts from other Apps.
+            appContext.registerReceiver(this, filter, Context.RECEIVER_NOT_EXPORTED);
+            mIsDataSaverEnabled = new AtomicBoolean();
+            updateDataSaverEnabled();
+        }
+
+        // Runs on caller's UI thread.
+        @Override
+        public void onReceive(Context context, Intent intent) {
+            if (intent.getAction().equals(ConnectivityManager.ACTION_RESTRICT_BACKGROUND_CHANGED)) {
+                updateDataSaverEnabled();
+            } else {
+                throw new IllegalStateException("Unexpected intent " + intent);
+            }
+        }
+
+        public boolean getDataSaverEnabled() {
+            return mIsDataSaverEnabled.get();
+        }
+
+        private void updateDataSaverEnabled() {
+            // Uid doesn't really matter, but use a fixed UID to make things clearer.
+            final int dataSaverForCallerUid = mNpm.getRestrictBackgroundStatus(Process.SYSTEM_UID);
+            mIsDataSaverEnabled.set(dataSaverForCallerUid
+                    != ConnectivityManager.RESTRICT_BACKGROUND_STATUS_DISABLED);
+        }
+    }
+
+    /**
+     * Return whether the network is blocked for the given uid and metered condition.
+     *
+     * Similar to {@link NetworkPolicyManager#isUidNetworkingBlocked}, but directly reads the BPF
+     * maps and therefore considerably faster. For use by the NetworkStack process only.
+     *
+     * @param uid The target uid.
+     * @param isNetworkMetered Whether the target network is metered.
+     *
+     * @return True if all networking with the given condition is blocked. Otherwise, false.
+     * @throws IllegalStateException if the map cannot be opened.
+     * @throws ServiceSpecificException if the read fails.
+     * @hide
+     */
+    // This isn't protected by a standard Android permission since it can't
+    // afford to do IPC for performance reasons. Instead, the access control
+    // is provided by linux file group permission AID_NET_BW_ACCT and the
+    // selinux context fs_bpf_net*.
+    // Only the system server process and the network stack have access.
+    @FlaggedApi(Flags.SUPPORT_IS_UID_NETWORKING_BLOCKED)
+    @SystemApi(client = MODULE_LIBRARIES)
+    @RequiresApi(Build.VERSION_CODES.TIRAMISU)  // BPF maps were only mainlined in T
+    @RequiresPermission(NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK)
+    public boolean isUidNetworkingBlocked(int uid, boolean isNetworkMetered) {
+        final BpfNetMapsReader reader = BpfNetMapsReader.getInstance();
+
+        final boolean isDataSaverEnabled;
+        // TODO: For U-QPR3+ devices, get data saver status from bpf configuration map directly.
+        final DataSaverStatusTracker dataSaverStatusTracker =
+                DataSaverStatusTracker.getInstance(mContext);
+        isDataSaverEnabled = dataSaverStatusTracker.getDataSaverEnabled();
+
+        return reader.isUidNetworkingBlocked(uid, isNetworkMetered, isDataSaverEnabled);
+    }
+
     /** @hide */
     public IBinder getCompanionDeviceManagerProxyService() {
         try {
@@ -6157,4 +6337,24 @@
             throw e.rethrowFromSystemServer();
         }
     }
+
+    private static final Object sRoutingCoordinatorManagerLock = new Object();
+    @GuardedBy("sRoutingCoordinatorManagerLock")
+    private static RoutingCoordinatorManager sRoutingCoordinatorManager = null;
+    /** @hide */
+    @RequiresApi(Build.VERSION_CODES.S)
+    public RoutingCoordinatorManager getRoutingCoordinatorManager() {
+        try {
+            synchronized (sRoutingCoordinatorManagerLock) {
+                if (null == sRoutingCoordinatorManager) {
+                    sRoutingCoordinatorManager = new RoutingCoordinatorManager(mContext,
+                            IRoutingCoordinator.Stub.asInterface(
+                                    mService.getRoutingCoordinatorService()));
+                }
+                return sRoutingCoordinatorManager;
+            }
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
 }
diff --git a/framework/src/android/net/ConnectivitySettingsManager.java b/framework/src/android/net/ConnectivitySettingsManager.java
index 67dacb8..ba7df7f 100644
--- a/framework/src/android/net/ConnectivitySettingsManager.java
+++ b/framework/src/android/net/ConnectivitySettingsManager.java
@@ -176,7 +176,9 @@
 
     /**
      * When detecting a captive portal, immediately disconnect from the
-     * network and do not reconnect to that network in the future.
+     * network and do not reconnect to that network in the future; except
+     * on Wear platform companion proxy networks (transport BLUETOOTH)
+     * will stay behind captive portal.
      */
     public static final int CAPTIVE_PORTAL_MODE_AVOID = 2;
 
diff --git a/framework/src/android/net/IConnectivityManager.aidl b/framework/src/android/net/IConnectivityManager.aidl
index ebe8bca..d3a02b9 100644
--- a/framework/src/android/net/IConnectivityManager.aidl
+++ b/framework/src/android/net/IConnectivityManager.aidl
@@ -27,6 +27,7 @@
 import android.net.IQosCallback;
 import android.net.ISocketKeepaliveCallback;
 import android.net.LinkProperties;
+import android.net.LocalNetworkConfig;
 import android.net.Network;
 import android.net.NetworkAgentConfig;
 import android.net.NetworkCapabilities;
@@ -146,7 +147,8 @@
     void declareNetworkRequestUnfulfillable(in NetworkRequest request);
 
     Network registerNetworkAgent(in INetworkAgent na, in NetworkInfo ni, in LinkProperties lp,
-            in NetworkCapabilities nc, in NetworkScore score, in NetworkAgentConfig config,
+            in NetworkCapabilities nc, in NetworkScore score,
+            in LocalNetworkConfig localNetworkConfig, in NetworkAgentConfig config,
             in int factorySerialNumber);
 
     NetworkRequest requestNetwork(int uid, in NetworkCapabilities networkCapabilities, int reqType,
@@ -238,6 +240,8 @@
 
     void setTestAllowBadWifiUntil(long timeMs);
 
+    void setDataSaverEnabled(boolean enable);
+
     void updateMeteredNetworkAllowList(int uid, boolean add);
 
     void updateMeteredNetworkDenyList(int uid, boolean add);
@@ -257,4 +261,6 @@
     void setVpnNetworkPreference(String session, in UidRange[] ranges);
 
     void setTestLowTcpPollingTimerForKeepalive(long timeMs);
+
+    IBinder getRoutingCoordinatorService();
 }
diff --git a/framework/src/android/net/INetworkAgentRegistry.aidl b/framework/src/android/net/INetworkAgentRegistry.aidl
index b375b7b..61b27b5 100644
--- a/framework/src/android/net/INetworkAgentRegistry.aidl
+++ b/framework/src/android/net/INetworkAgentRegistry.aidl
@@ -17,6 +17,7 @@
 
 import android.net.DscpPolicy;
 import android.net.LinkProperties;
+import android.net.LocalNetworkConfig;
 import android.net.Network;
 import android.net.NetworkCapabilities;
 import android.net.NetworkInfo;
@@ -34,6 +35,7 @@
     void sendLinkProperties(in LinkProperties lp);
     // TODO: consider replacing this by "markConnected()" and removing
     void sendNetworkInfo(in NetworkInfo info);
+    void sendLocalNetworkConfig(in LocalNetworkConfig config);
     void sendScore(in NetworkScore score);
     void sendExplicitlySelected(boolean explicitlySelected, boolean acceptPartial);
     void sendSocketKeepaliveEvent(int slot, int reason);
diff --git a/framework/src/android/net/IRoutingCoordinator.aidl b/framework/src/android/net/IRoutingCoordinator.aidl
new file mode 100644
index 0000000..cf02ec4
--- /dev/null
+++ b/framework/src/android/net/IRoutingCoordinator.aidl
@@ -0,0 +1,95 @@
+/*
+ * 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 android.net.RouteInfo;
+
+/** @hide */
+interface IRoutingCoordinator {
+   /**
+    * Add a route for specific network
+    *
+    * @param netId the network to add the route to
+    * @param route the route to add
+    * @throws ServiceSpecificException in case of failure, with an error code indicating the
+    *         cause of the failure.
+    */
+    void addRoute(int netId, in RouteInfo route);
+
+   /**
+    * Remove a route for specific network
+    *
+    * @param netId the network to remove the route from
+    * @param route the route to remove
+    * @throws ServiceSpecificException in case of failure, with an error code indicating the
+    *         cause of the failure.
+    */
+    void removeRoute(int netId, in RouteInfo route);
+
+    /**
+    * Update a route for specific network
+    *
+    * @param netId the network to update the route for
+    * @param route parcelable with route information
+    * @throws ServiceSpecificException in case of failure, with an error code indicating the
+    *         cause of the failure.
+    */
+    void updateRoute(int netId, in RouteInfo route);
+
+    /**
+     * Adds an interface to a network. The interface must not be assigned to any network, including
+     * the specified network.
+     *
+     * @param netId the network to add the interface to.
+     * @param iface the name of the interface to add.
+     *
+     * @throws ServiceSpecificException in case of failure, with an error code corresponding to the
+     *         unix errno.
+     */
+    void addInterfaceToNetwork(int netId, in String iface);
+
+    /**
+     * Removes an interface from a network. The interface must be assigned to the specified network.
+     *
+     * @param netId the network to remove the interface from.
+     * @param iface the name of the interface to remove.
+     *
+     * @throws ServiceSpecificException in case of failure, with an error code corresponding to the
+     *         unix errno.
+     */
+     void removeInterfaceFromNetwork(int netId, in String iface);
+
+   /**
+    * Add forwarding ip rule
+    *
+    * @param fromIface interface name to add forwarding ip rule
+    * @param toIface interface name to add forwarding ip rule
+    * @throws ServiceSpecificException in case of failure, with an error code indicating the
+    *         cause of the failure.
+    */
+    void addInterfaceForward(in String fromIface, in String toIface);
+
+   /**
+    * Remove forwarding ip rule
+    *
+    * @param fromIface interface name to remove forwarding ip rule
+    * @param toIface interface name to remove forwarding ip rule
+    * @throws ServiceSpecificException in case of failure, with an error code indicating the
+    *         cause of the failure.
+    */
+    void removeInterfaceForward(in String fromIface, in String toIface);
+}
diff --git a/framework/src/android/net/LocalNetworkConfig.java b/framework/src/android/net/LocalNetworkConfig.java
new file mode 100644
index 0000000..fca7fd1
--- /dev/null
+++ b/framework/src/android/net/LocalNetworkConfig.java
@@ -0,0 +1,168 @@
+/*
+ * 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 android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+/**
+ * A class to communicate configuration info about a local network through {@link NetworkAgent}.
+ * @hide
+ */
+// TODO : @SystemApi
+public final class LocalNetworkConfig implements Parcelable {
+    @Nullable
+    private final NetworkRequest mUpstreamSelector;
+
+    @NonNull
+    private final MulticastRoutingConfig mUpstreamMulticastRoutingConfig;
+
+    @NonNull
+    private final MulticastRoutingConfig mDownstreamMulticastRoutingConfig;
+
+    private LocalNetworkConfig(@Nullable final NetworkRequest upstreamSelector,
+            @Nullable final MulticastRoutingConfig upstreamConfig,
+            @Nullable final MulticastRoutingConfig downstreamConfig) {
+        mUpstreamSelector = upstreamSelector;
+        if (null != upstreamConfig) {
+            mUpstreamMulticastRoutingConfig = upstreamConfig;
+        } else {
+            mUpstreamMulticastRoutingConfig = MulticastRoutingConfig.CONFIG_FORWARD_NONE;
+        }
+        if (null != downstreamConfig) {
+            mDownstreamMulticastRoutingConfig = downstreamConfig;
+        } else {
+            mDownstreamMulticastRoutingConfig = MulticastRoutingConfig.CONFIG_FORWARD_NONE;
+        }
+    }
+
+    /**
+     * Get the request choosing which network traffic from this network is forwarded to and from.
+     *
+     * This may be null if the local network doesn't forward the traffic anywhere.
+     */
+    @Nullable
+    public NetworkRequest getUpstreamSelector() {
+        return mUpstreamSelector;
+    }
+
+    public @NonNull MulticastRoutingConfig getUpstreamMulticastRoutingConfig() {
+        return mUpstreamMulticastRoutingConfig;
+    }
+
+    public @NonNull MulticastRoutingConfig getDownstreamMulticastRoutingConfig() {
+        return mDownstreamMulticastRoutingConfig;
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @Override
+    public void writeToParcel(@NonNull final Parcel dest, final int flags) {
+        dest.writeParcelable(mUpstreamSelector, flags);
+        dest.writeParcelable(mUpstreamMulticastRoutingConfig, flags);
+        dest.writeParcelable(mDownstreamMulticastRoutingConfig, flags);
+    }
+
+    public static final @NonNull Creator<LocalNetworkConfig> CREATOR = new Creator<>() {
+        public LocalNetworkConfig createFromParcel(Parcel in) {
+            final NetworkRequest upstreamSelector = in.readParcelable(null);
+            final MulticastRoutingConfig upstreamConfig = in.readParcelable(null);
+            final MulticastRoutingConfig downstreamConfig = in.readParcelable(null);
+            return new LocalNetworkConfig(
+                    upstreamSelector, upstreamConfig, downstreamConfig);
+        }
+
+        @Override
+        public LocalNetworkConfig[] newArray(final int size) {
+            return new LocalNetworkConfig[size];
+        }
+    };
+
+
+    public static final class Builder {
+        @Nullable
+        NetworkRequest mUpstreamSelector;
+
+        @Nullable
+        MulticastRoutingConfig mUpstreamMulticastRoutingConfig;
+
+        @Nullable
+        MulticastRoutingConfig mDownstreamMulticastRoutingConfig;
+
+        /**
+         * Create a Builder
+         */
+        public Builder() {
+        }
+
+        /**
+         * Set to choose where this local network should forward its traffic to.
+         *
+         * The system will automatically choose the best network matching the request as an
+         * upstream, and set up forwarding between this local network and the chosen upstream.
+         * If no network matches the request, there is no upstream and the traffic is not forwarded.
+         * The caller can know when this changes by listening to link properties changes of
+         * this network with the {@link android.net.LinkProperties#getForwardedNetwork()} getter.
+         *
+         * Set this to null if the local network shouldn't be forwarded. Default is null.
+         */
+        @NonNull
+        public Builder setUpstreamSelector(@Nullable NetworkRequest upstreamSelector) {
+            mUpstreamSelector = upstreamSelector;
+            return this;
+        }
+
+        /**
+         * Set the upstream multicast routing config.
+         *
+         * If null, don't route multicast packets upstream. This is equivalent to a
+         * MulticastRoutingConfig in mode FORWARD_NONE. The default is null.
+         */
+        @NonNull
+        public Builder setUpstreamMulticastRoutingConfig(@Nullable MulticastRoutingConfig cfg) {
+            mUpstreamMulticastRoutingConfig = cfg;
+            return this;
+        }
+
+        /**
+         * Set the downstream multicast routing config.
+         *
+         * If null, don't route multicast packets downstream. This is equivalent to a
+         * MulticastRoutingConfig in mode FORWARD_NONE. The default is null.
+         */
+        @NonNull
+        public Builder setDownstreamMulticastRoutingConfig(@Nullable MulticastRoutingConfig cfg) {
+            mDownstreamMulticastRoutingConfig = cfg;
+            return this;
+        }
+
+        /**
+         * Build the LocalNetworkConfig object.
+         */
+        @NonNull
+        public LocalNetworkConfig build() {
+            return new LocalNetworkConfig(mUpstreamSelector,
+                    mUpstreamMulticastRoutingConfig,
+                    mDownstreamMulticastRoutingConfig);
+        }
+    }
+}
diff --git a/framework/src/android/net/MulticastRoutingConfig.java b/framework/src/android/net/MulticastRoutingConfig.java
new file mode 100644
index 0000000..ebd9fc5
--- /dev/null
+++ b/framework/src/android/net/MulticastRoutingConfig.java
@@ -0,0 +1,264 @@
+/*
+ * 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 android.annotation.IntDef;
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.util.ArraySet;
+import android.util.Log;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.net.Inet6Address;
+import java.net.UnknownHostException;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Set;
+
+/**
+ * A class representing a configuration for multicast routing.
+ *
+ * Internal usage to Connectivity
+ * @hide
+ */
+// TODO : @SystemApi
+public class MulticastRoutingConfig implements Parcelable {
+    private static final String TAG = MulticastRoutingConfig.class.getSimpleName();
+
+    /** Do not forward any multicast packets. */
+    public static final int FORWARD_NONE = 0;
+    /**
+     * Forward only multicast packets with destination in the list of listening addresses.
+     * Ignore the min scope.
+     */
+    public static final int FORWARD_SELECTED = 1;
+    /**
+     * Forward all multicast packets with scope greater or equal than the min scope.
+     * Ignore the list of listening addresses.
+     */
+    public static final int FORWARD_WITH_MIN_SCOPE = 2;
+
+    @Retention(RetentionPolicy.SOURCE)
+    @IntDef(prefix = { "FORWARD_" }, value = {
+            FORWARD_NONE,
+            FORWARD_SELECTED,
+            FORWARD_WITH_MIN_SCOPE
+    })
+    public @interface MulticastForwardingMode {}
+
+    /**
+     * Not a multicast scope, for configurations that do not use the min scope.
+     */
+    public static final int MULTICAST_SCOPE_NONE = -1;
+
+    public static final MulticastRoutingConfig CONFIG_FORWARD_NONE =
+            new MulticastRoutingConfig(FORWARD_NONE, MULTICAST_SCOPE_NONE, null);
+
+    @MulticastForwardingMode
+    private final int mForwardingMode;
+
+    private final int mMinScope;
+
+    @NonNull
+    private final Set<Inet6Address> mListeningAddresses;
+
+    private MulticastRoutingConfig(@MulticastForwardingMode final int mode, final int scope,
+            @Nullable final Set<Inet6Address> addresses) {
+        mForwardingMode = mode;
+        mMinScope = scope;
+        if (null != addresses) {
+            mListeningAddresses = Collections.unmodifiableSet(new ArraySet<>(addresses));
+        } else {
+            mListeningAddresses = Collections.emptySet();
+        }
+    }
+
+    /**
+     * Returns the forwarding mode.
+     */
+    @MulticastForwardingMode
+    public int getForwardingMode() {
+        return mForwardingMode;
+    }
+
+    /**
+     * Returns the minimal group address scope that is allowed for forwarding.
+     * If the forwarding mode is not FORWARD_WITH_MIN_SCOPE, will be MULTICAST_SCOPE_NONE.
+     */
+    public int getMinScope() {
+        return mMinScope;
+    }
+
+    /**
+     * Returns the list of group addresses listened by the outgoing interface.
+     * The list will be empty if the forwarding mode is not FORWARD_SELECTED.
+     */
+    @NonNull
+    public Set<Inet6Address> getMulticastListeningAddresses() {
+        return mListeningAddresses;
+    }
+
+    private MulticastRoutingConfig(Parcel in) {
+        mForwardingMode = in.readInt();
+        mMinScope = in.readInt();
+        final int count = in.readInt();
+        final ArraySet<Inet6Address> listeningAddresses = new ArraySet<>(count);
+        final byte[] buffer = new byte[16]; // Size of an Inet6Address
+        for (int i = 0; i < count; ++i) {
+            in.readByteArray(buffer);
+            try {
+                listeningAddresses.add((Inet6Address) Inet6Address.getByAddress(buffer));
+            } catch (UnknownHostException e) {
+                Log.wtf(TAG, "Can't read inet6address : " + Arrays.toString(buffer));
+            }
+        }
+        mListeningAddresses = Collections.unmodifiableSet(listeningAddresses);
+    }
+
+    @Override
+    public void writeToParcel(Parcel dest, int flags) {
+        dest.writeInt(mForwardingMode);
+        dest.writeInt(mMinScope);
+        dest.writeInt(mListeningAddresses.size());
+        for (final Inet6Address addr : mListeningAddresses) {
+            dest.writeByteArray(addr.getAddress());
+        }
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    public static final Creator<MulticastRoutingConfig> CREATOR = new Creator<>() {
+        @Override
+        public MulticastRoutingConfig createFromParcel(Parcel in) {
+            return new MulticastRoutingConfig(in);
+        }
+
+        @Override
+        public MulticastRoutingConfig[] newArray(int size) {
+            return new MulticastRoutingConfig[size];
+        }
+    };
+
+    public static class Builder {
+        @MulticastForwardingMode
+        private final int mForwardingMode;
+        private int mMinScope;
+        private final ArraySet<Inet6Address> mListeningAddresses;
+
+        private Builder(@MulticastForwardingMode final int mode, int scope) {
+            mForwardingMode = mode;
+            mMinScope = scope;
+            mListeningAddresses = new ArraySet<>();
+        }
+
+        /**
+         * Create a builder that forwards nothing.
+         * No properties can be set on such a builder.
+         */
+        public static Builder newBuilderForwardingNone() {
+            return new Builder(FORWARD_NONE, MULTICAST_SCOPE_NONE);
+        }
+
+        /**
+         * Create a builder that forwards packets above a certain scope
+         *
+         * The scope can be changed on this builder, but not the listening addresses.
+         * @param scope the initial scope
+         */
+        public static Builder newBuilderWithMinScope(final int scope) {
+            return new Builder(FORWARD_WITH_MIN_SCOPE, scope);
+        }
+
+        /**
+         * Create a builder that forwards a specified list of listening addresses.
+         *
+         * Addresses can be added and removed from this builder, but the scope can't be set.
+         */
+        public static Builder newBuilderWithListeningAddresses() {
+            return new Builder(FORWARD_SELECTED, MULTICAST_SCOPE_NONE);
+        }
+
+        /**
+         * Sets the minimum scope for this multicast routing config.
+         * This is only meaningful (indeed, allowed) for configs in FORWARD_WITH_MIN_SCOPE mode.
+         * @return this builder
+         */
+        public Builder setMinimumScope(final int scope) {
+            if (FORWARD_WITH_MIN_SCOPE != mForwardingMode) {
+                throw new IllegalArgumentException("Can't set the scope on a builder in mode "
+                        + modeToString(mForwardingMode));
+            }
+            mMinScope = scope;
+            return this;
+        }
+
+        /**
+         * Add an address to the set of listening addresses.
+         *
+         * This is only meaningful (indeed, allowed) for configs in FORWARD_SELECTED mode.
+         * If this address was already added, this is a no-op.
+         * @return this builder
+         */
+        public Builder addListeningAddress(@NonNull final Inet6Address address) {
+            if (FORWARD_SELECTED != mForwardingMode) {
+                throw new IllegalArgumentException("Can't add an address on a builder in mode "
+                        + modeToString(mForwardingMode));
+            }
+            // TODO : should we check that this is a multicast address ?
+            mListeningAddresses.add(address);
+            return this;
+        }
+
+        /**
+         * Remove an address from the set of listening addresses.
+         *
+         * This is only meaningful (indeed, allowed) for configs in FORWARD_SELECTED mode.
+         * If this address was not added, or was already removed, this is a no-op.
+         * @return this builder
+         */
+        public Builder removeListeningAddress(@NonNull final Inet6Address address) {
+            if (FORWARD_SELECTED != mForwardingMode) {
+                throw new IllegalArgumentException("Can't remove an address on a builder in mode "
+                        + modeToString(mForwardingMode));
+            }
+            mListeningAddresses.remove(address);
+            return this;
+        }
+
+        /**
+         * Build the config.
+         */
+        public MulticastRoutingConfig build() {
+            return new MulticastRoutingConfig(mForwardingMode, mMinScope, mListeningAddresses);
+        }
+    }
+
+    private static String modeToString(@MulticastForwardingMode final int mode) {
+        switch (mode) {
+            case FORWARD_NONE: return "FORWARD_NONE";
+            case FORWARD_SELECTED: return "FORWARD_SELECTED";
+            case FORWARD_WITH_MIN_SCOPE: return "FORWARD_WITH_MIN_SCOPE";
+            default: return "unknown multicast routing mode " + mode;
+        }
+    }
+}
diff --git a/framework/src/android/net/NetworkAgent.java b/framework/src/android/net/NetworkAgent.java
index 177f7e3..4e9087c 100644
--- a/framework/src/android/net/NetworkAgent.java
+++ b/framework/src/android/net/NetworkAgent.java
@@ -151,7 +151,7 @@
 
     /**
      * Sent by the NetworkAgent to ConnectivityService to pass the current
-     * NetworkCapabilties.
+     * NetworkCapabilities.
      * obj = NetworkCapabilities
      * @hide
      */
@@ -443,6 +443,14 @@
     public static final int EVENT_UNREGISTER_AFTER_REPLACEMENT = BASE + 29;
 
     /**
+     * Sent by the NetworkAgent to ConnectivityService to pass the new value of the local
+     * network agent config.
+     * obj = {@code Pair<NetworkAgentInfo, LocalNetworkConfig>}
+     * @hide
+     */
+    public static final int EVENT_LOCAL_NETWORK_CONFIG_CHANGED = BASE + 30;
+
+    /**
      * DSCP policy was successfully added.
      */
     public static final int DSCP_POLICY_STATUS_SUCCESS = 0;
@@ -517,20 +525,47 @@
             @NonNull NetworkCapabilities nc, @NonNull LinkProperties lp,
             @NonNull NetworkScore score, @NonNull NetworkAgentConfig config,
             @Nullable NetworkProvider provider) {
-        this(looper, context, logTag, nc, lp, score, config,
+        this(context, looper, logTag, nc, lp, null /* localNetworkConfig */, score, config,
+                provider);
+    }
+
+    /**
+     * Create a new network agent.
+     * @param context a {@link Context} to get system services from.
+     * @param looper the {@link Looper} on which to invoke the callbacks.
+     * @param logTag the tag for logs
+     * @param nc the initial {@link NetworkCapabilities} of this network. Update with
+     *           sendNetworkCapabilities.
+     * @param lp the initial {@link LinkProperties} of this network. Update with sendLinkProperties.
+     * @param localNetworkConfig the initial {@link LocalNetworkConfig} of this
+     *                                  network. Update with sendLocalNetworkConfig. Must be
+     *                                  non-null iff the nc have NET_CAPABILITY_LOCAL_NETWORK.
+     * @param score the initial score of this network. Update with sendNetworkScore.
+     * @param config an immutable {@link NetworkAgentConfig} for this agent.
+     * @param provider the {@link NetworkProvider} managing this agent.
+     * @hide
+     */
+    // TODO : expose
+    public NetworkAgent(@NonNull Context context, @NonNull Looper looper, @NonNull String logTag,
+            @NonNull NetworkCapabilities nc, @NonNull LinkProperties lp,
+            @Nullable LocalNetworkConfig localNetworkConfig, @NonNull NetworkScore score,
+            @NonNull NetworkAgentConfig config, @Nullable NetworkProvider provider) {
+        this(looper, context, logTag, nc, lp, localNetworkConfig, score, config,
                 provider == null ? NetworkProvider.ID_NONE : provider.getProviderId(),
                 getLegacyNetworkInfo(config));
     }
 
     private static class InitialConfiguration {
-        public final Context context;
-        public final NetworkCapabilities capabilities;
-        public final LinkProperties properties;
-        public final NetworkScore score;
-        public final NetworkAgentConfig config;
-        public final NetworkInfo info;
+        @NonNull public final Context context;
+        @NonNull public final NetworkCapabilities capabilities;
+        @NonNull public final LinkProperties properties;
+        @NonNull public final NetworkScore score;
+        @NonNull public final NetworkAgentConfig config;
+        @NonNull public final NetworkInfo info;
+        @Nullable public final LocalNetworkConfig localNetworkConfig;
         InitialConfiguration(@NonNull Context context, @NonNull NetworkCapabilities capabilities,
-                @NonNull LinkProperties properties, @NonNull NetworkScore score,
+                @NonNull LinkProperties properties,
+                @Nullable LocalNetworkConfig localNetworkConfig, @NonNull NetworkScore score,
                 @NonNull NetworkAgentConfig config, @NonNull NetworkInfo info) {
             this.context = context;
             this.capabilities = capabilities;
@@ -538,14 +573,15 @@
             this.score = score;
             this.config = config;
             this.info = info;
+            this.localNetworkConfig = localNetworkConfig;
         }
     }
     private volatile InitialConfiguration mInitialConfiguration;
 
     private NetworkAgent(@NonNull Looper looper, @NonNull Context context, @NonNull String logTag,
             @NonNull NetworkCapabilities nc, @NonNull LinkProperties lp,
-            @NonNull NetworkScore score, @NonNull NetworkAgentConfig config, int providerId,
-            @NonNull NetworkInfo ni) {
+            @Nullable LocalNetworkConfig localNetworkConfig, @NonNull NetworkScore score,
+            @NonNull NetworkAgentConfig config, int providerId, @NonNull NetworkInfo ni) {
         mHandler = new NetworkAgentHandler(looper);
         LOG_TAG = logTag;
         mNetworkInfo = new NetworkInfo(ni);
@@ -556,7 +592,7 @@
 
         mInitialConfiguration = new InitialConfiguration(context,
                 new NetworkCapabilities(nc, NetworkCapabilities.REDACT_NONE),
-                new LinkProperties(lp), score, config, ni);
+                new LinkProperties(lp), localNetworkConfig, score, config, ni);
     }
 
     private class NetworkAgentHandler extends Handler {
@@ -723,7 +759,8 @@
             mNetwork = cm.registerNetworkAgent(new NetworkAgentBinder(mHandler),
                     new NetworkInfo(mInitialConfiguration.info),
                     mInitialConfiguration.properties, mInitialConfiguration.capabilities,
-                    mInitialConfiguration.score, mInitialConfiguration.config, providerId);
+                    mInitialConfiguration.localNetworkConfig, mInitialConfiguration.score,
+                    mInitialConfiguration.config, providerId);
             mInitialConfiguration = null; // All this memory can now be GC'd
         }
         return mNetwork;
@@ -1099,6 +1136,18 @@
     }
 
     /**
+     * Must be called by the agent when the network's {@link LocalNetworkConfig} changes.
+     * @param config the new LocalNetworkConfig
+     * @hide
+     */
+    public void sendLocalNetworkConfig(@NonNull LocalNetworkConfig config) {
+        Objects.requireNonNull(config);
+        // If the agent doesn't have NET_CAPABILITY_LOCAL_NETWORK, this will be ignored by
+        // ConnectivityService with a Log.wtf.
+        queueOrSendMessage(reg -> reg.sendLocalNetworkConfig(config));
+    }
+
+    /**
      * Must be called by the agent to update the score of this network.
      *
      * @param score the new score.
diff --git a/framework/src/android/net/NetworkCapabilities.java b/framework/src/android/net/NetworkCapabilities.java
index abda1fa..f959114 100644
--- a/framework/src/android/net/NetworkCapabilities.java
+++ b/framework/src/android/net/NetworkCapabilities.java
@@ -29,6 +29,9 @@
 import android.annotation.SystemApi;
 import android.compat.annotation.UnsupportedAppUsage;
 import android.net.ConnectivityManager.NetworkCallback;
+// Can't be imported because aconfig tooling doesn't exist on udc-mainline-prod yet
+// See inner class Flags which mimics this for the time being
+// import android.net.flags.Flags;
 import android.os.Build;
 import android.os.Parcel;
 import android.os.Parcelable;
@@ -121,6 +124,14 @@
 public final class NetworkCapabilities implements Parcelable {
     private static final String TAG = "NetworkCapabilities";
 
+    // TODO : remove this class when udc-mainline-prod is abandoned and android.net.flags.Flags is
+    // available here
+    /** @hide */
+    public static class Flags {
+        static final String FLAG_FORBIDDEN_CAPABILITY =
+                "com.android.net.flags.forbidden_capability";
+    }
+
     /**
      * Mechanism to support redaction of fields in NetworkCapabilities that are guarded by specific
      * app permissions.
@@ -442,6 +453,7 @@
             NET_CAPABILITY_MMTEL,
             NET_CAPABILITY_PRIORITIZE_LATENCY,
             NET_CAPABILITY_PRIORITIZE_BANDWIDTH,
+            NET_CAPABILITY_LOCAL_NETWORK,
     })
     public @interface NetCapability { }
 
@@ -703,7 +715,21 @@
      */
     public static final int NET_CAPABILITY_PRIORITIZE_BANDWIDTH = 35;
 
-    private static final int MAX_NET_CAPABILITY = NET_CAPABILITY_PRIORITIZE_BANDWIDTH;
+    /**
+     * This is a local network, e.g. a tethering downstream or a P2P direct network.
+     *
+     * <p>
+     * Note that local networks are not sent to callbacks by default. To receive callbacks about
+     * them, the {@link NetworkRequest} instance must be prepared to see them, either by
+     * adding the capability with {@link NetworkRequest.Builder#addCapability}, by removing
+     * this forbidden capability with {@link NetworkRequest.Builder#removeForbiddenCapability},
+     * or by clearing all capabilites with {@link NetworkRequest.Builder#clearCapabilities()}.
+     * </p>
+     * @hide
+     */
+    public static final int NET_CAPABILITY_LOCAL_NETWORK = 36;
+
+    private static final int MAX_NET_CAPABILITY = NET_CAPABILITY_LOCAL_NETWORK;
 
     // Set all bits up to the MAX_NET_CAPABILITY-th bit
     private static final long ALL_VALID_CAPABILITIES = (2L << MAX_NET_CAPABILITY) - 1;
@@ -793,6 +819,10 @@
      * Adds the given capability to this {@code NetworkCapability} instance.
      * Note that when searching for a network to satisfy a request, all capabilities
      * requested must be satisfied.
+     * <p>
+     * If the capability was previously added to the list of forbidden capabilities (either
+     * by default or added using {@link #addForbiddenCapability(int)}), then it will be removed
+     * from the list of forbidden capabilities as well.
      *
      * @param capability the capability to be added.
      * @return This NetworkCapabilities instance, to facilitate chaining.
@@ -801,8 +831,7 @@
     public @NonNull NetworkCapabilities addCapability(@NetCapability int capability) {
         // If the given capability was previously added to the list of forbidden capabilities
         // then the capability will also be removed from the list of forbidden capabilities.
-        // TODO: Consider adding forbidden capabilities to the public API and mention this
-        // in the documentation.
+        // TODO: Add forbidden capabilities to the public API
         checkValidCapability(capability);
         mNetworkCapabilities |= 1L << capability;
         // remove from forbidden capability list
@@ -845,7 +874,7 @@
     }
 
     /**
-     * Removes (if found) the given forbidden capability from this {@code NetworkCapability}
+     * Removes (if found) the given forbidden capability from this {@link NetworkCapabilities}
      * instance that were added via addForbiddenCapability(int) or setCapabilities(int[], int[]).
      *
      * @param capability the capability to be removed.
@@ -859,6 +888,16 @@
     }
 
     /**
+     * Removes all forbidden capabilities from this {@link NetworkCapabilities} instance.
+     * @return This NetworkCapabilities instance, to facilitate chaining.
+     * @hide
+     */
+    public @NonNull NetworkCapabilities removeAllForbiddenCapabilities() {
+        mForbiddenNetworkCapabilities = 0;
+        return this;
+    }
+
+    /**
      * Sets (or clears) the given capability on this {@link NetworkCapabilities}
      * instance.
      * @hide
@@ -901,6 +940,8 @@
      * @return an array of forbidden capability values for this instance.
      * @hide
      */
+    @NonNull
+    // TODO : @FlaggedApi(Flags.FLAG_FORBIDDEN_CAPABILITY) and public
     public @NetCapability int[] getForbiddenCapabilities() {
         return BitUtils.unpackBits(mForbiddenNetworkCapabilities);
     }
@@ -1000,7 +1041,7 @@
     /**
      * Tests for the presence of a capability on this instance.
      *
-     * @param capability the capabilities to be tested for.
+     * @param capability the capability to be tested for.
      * @return {@code true} if set on this instance.
      */
     public boolean hasCapability(@NetCapability int capability) {
@@ -1008,19 +1049,27 @@
                 && ((mNetworkCapabilities & (1L << capability)) != 0);
     }
 
-    /** @hide */
+    /**
+     * Tests for the presence of a forbidden capability on this instance.
+     *
+     * @param capability the capability to be tested for.
+     * @return {@code true} if this capability is set forbidden on this instance.
+     * @hide
+     */
     @SystemApi(client = SystemApi.Client.MODULE_LIBRARIES)
+    // TODO : @FlaggedApi(Flags.FLAG_FORBIDDEN_CAPABILITY) and public
     public boolean hasForbiddenCapability(@NetCapability int capability) {
         return isValidCapability(capability)
                 && ((mForbiddenNetworkCapabilities & (1L << capability)) != 0);
     }
 
     /**
-     * Check if this NetworkCapabilities has system managed capabilities or not.
+     * Check if this NetworkCapabilities has connectivity-managed capabilities or not.
      * @hide
      */
     public boolean hasConnectivityManagedCapability() {
-        return ((mNetworkCapabilities & CONNECTIVITY_MANAGED_CAPABILITIES) != 0);
+        return (mNetworkCapabilities & CONNECTIVITY_MANAGED_CAPABILITIES) != 0
+                || mForbiddenNetworkCapabilities != 0;
     }
 
     /**
@@ -2500,6 +2549,7 @@
             case NET_CAPABILITY_MMTEL:                return "MMTEL";
             case NET_CAPABILITY_PRIORITIZE_LATENCY:          return "PRIORITIZE_LATENCY";
             case NET_CAPABILITY_PRIORITIZE_BANDWIDTH:        return "PRIORITIZE_BANDWIDTH";
+            case NET_CAPABILITY_LOCAL_NETWORK:        return "LOCAL_NETWORK";
             default:                                  return Integer.toString(capability);
         }
     }
@@ -2889,6 +2939,44 @@
         }
 
         /**
+         * Adds the given capability to the list of forbidden capabilities.
+         *
+         * A network with a capability will not match a {@link NetworkCapabilities} or
+         * {@link NetworkRequest} which has said capability set as forbidden. For example, if
+         * a request has NET_CAPABILITY_INTERNET in the list of forbidden capabilities, networks
+         * with NET_CAPABILITY_INTERNET will not match the request.
+         *
+         * If the capability was previously added to the list of required capabilities (for
+         * example, it was there by default or added using {@link #addCapability(int)} method), then
+         * it will be removed from the list of required capabilities as well.
+         *
+         * @param capability the capability
+         * @return this builder
+         * @hide
+         */
+        @NonNull
+        // TODO : @FlaggedApi(Flags.FLAG_FORBIDDEN_CAPABILITY) and public
+        public Builder addForbiddenCapability(@NetCapability final int capability) {
+            mCaps.addForbiddenCapability(capability);
+            return this;
+        }
+
+        /**
+         * Removes the given capability from the list of forbidden capabilities.
+         *
+         * @see #addForbiddenCapability(int)
+         * @param capability the capability
+         * @return this builder
+         * @hide
+         */
+        @NonNull
+        // TODO : @FlaggedApi(Flags.FLAG_FORBIDDEN_CAPABILITY) and public
+        public Builder removeForbiddenCapability(@NetCapability final int capability) {
+            mCaps.removeForbiddenCapability(capability);
+            return this;
+        }
+
+        /**
          * Adds the given enterprise capability identifier.
          * Note that when searching for a network to satisfy a request, all capabilities identifier
          * requested must be satisfied. Enterprise capability identifier is applicable only
@@ -3235,4 +3323,4 @@
             return new NetworkCapabilities(mCaps);
         }
     }
-}
\ No newline at end of file
+}
diff --git a/framework/src/android/net/NetworkRequest.java b/framework/src/android/net/NetworkRequest.java
index 6c351d0..9824faa 100644
--- a/framework/src/android/net/NetworkRequest.java
+++ b/framework/src/android/net/NetworkRequest.java
@@ -20,6 +20,7 @@
 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;
@@ -39,6 +40,8 @@
 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;
@@ -281,6 +284,15 @@
                 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[] {
+            NET_CAPABILITY_LOCAL_NETWORK
+        };
+
         private final NetworkCapabilities mNetworkCapabilities;
 
         // A boolean that represents whether the NOT_VCN_MANAGED capability should be deduced when
@@ -296,6 +308,16 @@
             // 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);
+            }
         }
 
         /**
@@ -408,6 +430,7 @@
         @NonNull
         @SuppressLint("MissingGetterMatchingBuilder")
         @SystemApi(client = SystemApi.Client.MODULE_LIBRARIES)
+        // TODO : @FlaggedApi(Flags.FLAG_FORBIDDEN_CAPABILITY) and public
         public Builder addForbiddenCapability(@NetworkCapabilities.NetCapability int capability) {
             mNetworkCapabilities.addForbiddenCapability(capability);
             return this;
@@ -424,6 +447,7 @@
         @NonNull
         @SuppressLint("BuilderSetStyle")
         @SystemApi(client = SystemApi.Client.MODULE_LIBRARIES)
+        // TODO : @FlaggedApi(Flags.FLAG_FORBIDDEN_CAPABILITY) and public
         public Builder removeForbiddenCapability(
                 @NetworkCapabilities.NetCapability int capability) {
             mNetworkCapabilities.removeForbiddenCapability(capability);
@@ -433,6 +457,7 @@
         /**
          * Completely clears all the {@code NetworkCapabilities} from this builder instance,
          * removing even the capabilities that are set by default when the object is constructed.
+         * Also removes any set forbidden capabilities.
          *
          * @return The builder to facilitate chaining.
          */
@@ -721,6 +746,7 @@
      * @hide
      */
     @SystemApi(client = SystemApi.Client.MODULE_LIBRARIES)
+    // TODO : @FlaggedApi(Flags.FLAG_FORBIDDEN_CAPABILITY) and public instead of @SystemApi
     public boolean hasForbiddenCapability(@NetCapability int capability) {
         return networkCapabilities.hasForbiddenCapability(capability);
     }
@@ -843,6 +869,7 @@
      */
     @NonNull
     @SystemApi(client = SystemApi.Client.MODULE_LIBRARIES)
+    // TODO : @FlaggedApi(Flags.FLAG_FORBIDDEN_CAPABILITY) and public instead of @SystemApi
     public @NetCapability int[] getForbiddenCapabilities() {
         // No need to make a defensive copy here as NC#getForbiddenCapabilities() already returns
         // a new array.
diff --git a/framework/src/android/net/NetworkScore.java b/framework/src/android/net/NetworkScore.java
index 815e2b0..935dea1 100644
--- a/framework/src/android/net/NetworkScore.java
+++ b/framework/src/android/net/NetworkScore.java
@@ -44,7 +44,9 @@
     @Retention(RetentionPolicy.SOURCE)
     @IntDef(value = {
             KEEP_CONNECTED_NONE,
-            KEEP_CONNECTED_FOR_HANDOVER
+            KEEP_CONNECTED_FOR_HANDOVER,
+            KEEP_CONNECTED_FOR_TEST,
+            KEEP_CONNECTED_LOCAL_NETWORK
     })
     public @interface KeepConnectedReason { }
 
@@ -57,6 +59,18 @@
      * is being considered for handover.
      */
     public static final int KEEP_CONNECTED_FOR_HANDOVER = 1;
+    /**
+     * Keep this network connected even if there is no outstanding request for it, because it
+     * is used in a test and it's not necessarily easy to file the right request for it.
+     * @hide
+     */
+    public static final int KEEP_CONNECTED_FOR_TEST = 2;
+    /**
+     * Keep this network connected even if there is no outstanding request for it, because
+     * it is a local network.
+     * @hide
+     */
+    public static final int KEEP_CONNECTED_LOCAL_NETWORK = 3;
 
     // Agent-managed policies
     // This network should lose to a wifi that has ever been validated
diff --git a/framework/src/android/net/RouteInfo.java b/framework/src/android/net/RouteInfo.java
index df5f151..e8ebf81 100644
--- a/framework/src/android/net/RouteInfo.java
+++ b/framework/src/android/net/RouteInfo.java
@@ -584,7 +584,7 @@
             }
             RouteKey p = (RouteKey) o;
             // No need to do anything special for scoped addresses. Inet6Address#equals does not
-            // consider the scope ID, but the netd route IPCs (e.g., INetd#networkAddRouteParcel)
+            // consider the scope ID, but the route IPCs (e.g., RoutingCoordinatorManager#addRoute)
             // and the kernel ignore scoped addresses both in the prefix and in the nexthop and only
             // look at RTA_OIF.
             return Objects.equals(p.mDestination, mDestination)
diff --git a/framework/src/android/net/RoutingCoordinatorManager.java b/framework/src/android/net/RoutingCoordinatorManager.java
new file mode 100644
index 0000000..a9e7eef
--- /dev/null
+++ b/framework/src/android/net/RoutingCoordinatorManager.java
@@ -0,0 +1,158 @@
+/*
+ * 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 android.content.Context;
+import android.os.Build;
+import android.os.RemoteException;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RequiresApi;
+
+/**
+ * A manager class for talking to the routing coordinator service.
+ *
+ * This class should only be used by the connectivity and tethering module. This is enforced
+ * by the build rules. Do not change build rules to gain access to this class from elsewhere.
+ * @hide
+ */
+@RequiresApi(Build.VERSION_CODES.S)
+public class RoutingCoordinatorManager {
+    @NonNull final Context mContext;
+    @NonNull final IRoutingCoordinator mService;
+
+    public RoutingCoordinatorManager(@NonNull final Context context,
+            @NonNull final IRoutingCoordinator service) {
+        mContext = context;
+        mService = service;
+    }
+
+    /**
+     * Add a route for specific network
+     *
+     * @param netId the network to add the route to
+     * @param route the route to add
+     * @throws ServiceSpecificException in case of failure, with an error code indicating the
+     *         cause of the failure.
+     */
+    public void addRoute(final int netId, final RouteInfo route) {
+        try {
+            mService.addRoute(netId, route);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Remove a route for specific network
+     *
+     * @param netId the network to remove the route from
+     * @param route the route to remove
+     * @throws ServiceSpecificException in case of failure, with an error code indicating the
+     *         cause of the failure.
+     */
+    public void removeRoute(final int netId, final RouteInfo route) {
+        try {
+            mService.removeRoute(netId, route);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Update a route for specific network
+     *
+     * @param netId the network to update the route for
+     * @param route parcelable with route information
+     * @throws ServiceSpecificException in case of failure, with an error code indicating the
+     *         cause of the failure.
+     */
+    public void updateRoute(final int netId, final RouteInfo route) {
+        try {
+            mService.updateRoute(netId, route);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Adds an interface to a network. The interface must not be assigned to any network, including
+     * the specified network.
+     *
+     * @param netId the network to add the interface to.
+     * @param iface the name of the interface to add.
+     *
+     * @throws ServiceSpecificException in case of failure, with an error code corresponding to the
+     *         unix errno.
+     */
+    public void addInterfaceToNetwork(final int netId, final String iface) {
+        try {
+            mService.addInterfaceToNetwork(netId, iface);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Removes an interface from a network. The interface must be assigned to the specified network.
+     *
+     * @param netId the network to remove the interface from.
+     * @param iface the name of the interface to remove.
+     *
+     * @throws ServiceSpecificException in case of failure, with an error code corresponding to the
+     *         unix errno.
+     */
+    public void removeInterfaceFromNetwork(final int netId, final String iface) {
+        try {
+            mService.removeInterfaceFromNetwork(netId, iface);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Add forwarding ip rule
+     *
+     * @param fromIface interface name to add forwarding ip rule
+     * @param toIface interface name to add forwarding ip rule
+     * @throws ServiceSpecificException in case of failure, with an error code indicating the
+     *         cause of the failure.
+     */
+    public void addInterfaceForward(final String fromIface, final String toIface) {
+        try {
+            mService.addInterfaceForward(fromIface, toIface);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Remove forwarding ip rule
+     *
+     * @param fromIface interface name to remove forwarding ip rule
+     * @param toIface interface name to remove forwarding ip rule
+     * @throws ServiceSpecificException in case of failure, with an error code indicating the
+     *         cause of the failure.
+     */
+    public void removeInterfaceForward(final String fromIface, final String toIface) {
+        try {
+            mService.removeInterfaceForward(fromIface, toIface);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+}
diff --git a/service/src/com/android/server/UidOwnerValue.java b/framework/src/android/net/UidOwnerValue.java
similarity index 86%
rename from service/src/com/android/server/UidOwnerValue.java
rename to framework/src/android/net/UidOwnerValue.java
index d6c0e0d..e8ae604 100644
--- a/service/src/com/android/server/UidOwnerValue.java
+++ b/framework/src/android/net/UidOwnerValue.java
@@ -1,5 +1,5 @@
 /*
- * Copyright (C) 2022 The Android Open Source Project
+ * 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.
@@ -14,11 +14,15 @@
  * limitations under the License.
  */
 
-package com.android.server;
+package android.net;
 
 import com.android.net.module.util.Struct;
 
-/** Value type for per uid traffic control configuration map  */
+/**
+ * Value type for per uid traffic control configuration map.
+ *
+ * @hide
+ */
 public class UidOwnerValue extends Struct {
     // Allowed interface index. Only applicable if IIF_MATCH is set in the rule bitmask below.
     @Field(order = 0, type = Type.S32)
diff --git a/framework/src/android/net/connectivity/TiramisuConnectivityInternalApiUtil.java b/framework/src/android/net/connectivity/TiramisuConnectivityInternalApiUtil.java
index d65858f..c2d75d2 100644
--- a/framework/src/android/net/connectivity/TiramisuConnectivityInternalApiUtil.java
+++ b/framework/src/android/net/connectivity/TiramisuConnectivityInternalApiUtil.java
@@ -18,6 +18,7 @@
 
 import android.content.Context;
 import android.net.ConnectivityManager;
+import android.net.RoutingCoordinatorManager;
 import android.os.Build;
 import android.os.IBinder;
 
@@ -34,15 +35,28 @@
  * linter).
  * @hide
  */
-@RequiresApi(Build.VERSION_CODES.TIRAMISU)
+// TODO : rename this so that it doesn't reference "Tiramisu" since it can be used in S.
+@RequiresApi(Build.VERSION_CODES.S)
 public class TiramisuConnectivityInternalApiUtil {
 
     /**
      * Get a service binder token for
      * {@link com.android.server.connectivity.wear.CompanionDeviceManagerProxyService}.
      */
+    @RequiresApi(Build.VERSION_CODES.TIRAMISU)
     public static IBinder getCompanionDeviceManagerProxyService(Context ctx) {
         final ConnectivityManager cm = ctx.getSystemService(ConnectivityManager.class);
         return cm.getCompanionDeviceManagerProxyService();
     }
+
+    /**
+     * Obtain a routing coordinator manager from a context, possibly cross-module.
+     * @param ctx the context
+     * @return an instance of the coordinator manager
+     */
+    @RequiresApi(Build.VERSION_CODES.S)
+    public static RoutingCoordinatorManager getRoutingCoordinatorManager(Context ctx) {
+        final ConnectivityManager cm = ctx.getSystemService(ConnectivityManager.class);
+        return cm.getRoutingCoordinatorManager();
+    }
 }
diff --git a/framework/udc-extended-api/system-current.txt b/framework/udc-extended-api/system-current.txt
index 4a2ed8a..e812024 100644
--- a/framework/udc-extended-api/system-current.txt
+++ b/framework/udc-extended-api/system-current.txt
@@ -94,6 +94,7 @@
   }
 
   public final class DscpPolicy implements android.os.Parcelable {
+    method public int describeContents();
     method @Nullable public java.net.InetAddress getDestinationAddress();
     method @Nullable public android.util.Range<java.lang.Integer> getDestinationPortRange();
     method public int getDscpValue();
@@ -101,6 +102,7 @@
     method public int getProtocol();
     method @Nullable public java.net.InetAddress getSourceAddress();
     method public int getSourcePort();
+    method public void writeToParcel(@NonNull android.os.Parcel, int);
     field @NonNull public static final android.os.Parcelable.Creator<android.net.DscpPolicy> CREATOR;
     field public static final int PROTOCOL_ANY = -1; // 0xffffffff
     field public static final int SOURCE_PORT_ANY = -1; // 0xffffffff
diff --git a/nearby/README.md b/nearby/README.md
index 6925dc4..8451882 100644
--- a/nearby/README.md
+++ b/nearby/README.md
@@ -29,6 +29,20 @@
 $ aidegen .
 # This will launch Intellij project for Nearby module.
 ```
+Note, the setup above may fail to index classes defined in proto, such
+that all classes defined in proto shows red in IDE and cannot be auto-completed.
+To fix, you can mannually add jar files generated from proto to the class path
+as below.  First, find the jar file of presence proto with
+```sh
+ls $ANDROID_BUILD_TOP/out/soong/.intermediates/packages/modules/Connectivity/nearby/service/proto/presence-lite-protos/android_common/combined/presence-lite-protos.jar
+```
+Then, add the jar in IDE as below.
+1. Menu: File > Project Structure
+2. Select Modules at the left panel and select the Dependencies tab.
+3. Select the + icon and select 1 JARs or Directories option.
+4. Select the JAR file found above, make sure it is checked in the beginning square.
+5. Click the OK button.
+6. Restart the IDE to re-index.
 
 ## Build and Install
 
@@ -40,3 +54,23 @@
     --output /tmp/tethering.apex
 $ adb install -r /tmp/tethering.apex
 ```
+
+## Build and Install from tm-mainline-prod branch
+When build and flash the APEX from tm-mainline-prod, you may see the error below.
+```
+[INSTALL_FAILED_VERSION_DOWNGRADE: Downgrade of APEX package com.google.android.tethering is not allowed. Active version: 990090000 attempted: 339990000])
+```
+This is because the device is flashed with AOSP built from master or other branches, which has
+prebuilt APEX with higher version. We can use root access to replace the prebuilt APEX with the APEX
+built from tm-mainline-prod as below.
+1. adb root && adb remount; adb shell mount -orw,remount /system/apex
+2. cp tethering.next.apex com.google.android.tethering.apex
+3. adb push  com.google.android.tethering.apex  /system/apex/
+4. adb reboot
+After the steps above, the APEX can be reinstalled with adb install -r.
+(More APEX background can be found in https://source.android.com/docs/core/ota/apex#using-an-apex.)
+
+## Build APEX to support multiple platforms
+If you need to flash the APEX to different devices, Pixel 6, Pixel 7, or even devices from OEM, you
+can share the APEX by ```source build/envsetup.sh && lunch aosp_arm64-userdebug```. This can avoid
+ re-compiling for different targets.
diff --git a/nearby/framework/java/android/nearby/DataElement.java b/nearby/framework/java/android/nearby/DataElement.java
index 6fa5fb5..10c1132 100644
--- a/nearby/framework/java/android/nearby/DataElement.java
+++ b/nearby/framework/java/android/nearby/DataElement.java
@@ -16,13 +16,17 @@
 
 package android.nearby;
 
+import android.annotation.IntDef;
 import android.annotation.NonNull;
+import android.annotation.Nullable;
 import android.annotation.SystemApi;
 import android.os.Parcel;
 import android.os.Parcelable;
 
 import com.android.internal.util.Preconditions;
 
+import java.util.Arrays;
+import java.util.Objects;
 
 /**
  * Represents a data element in Nearby Presence.
@@ -35,11 +39,95 @@
     private final int mKey;
     private final byte[] mValue;
 
+    /** @hide */
+    @IntDef({
+            DataType.BLE_SERVICE_DATA,
+            DataType.BLE_ADDRESS,
+            DataType.SALT,
+            DataType.PRIVATE_IDENTITY,
+            DataType.TRUSTED_IDENTITY,
+            DataType.PUBLIC_IDENTITY,
+            DataType.PROVISIONED_IDENTITY,
+            DataType.TX_POWER,
+            DataType.ACTION,
+            DataType.MODEL_ID,
+            DataType.EDDYSTONE_EPHEMERAL_IDENTIFIER,
+            DataType.ACCOUNT_KEY_DATA,
+            DataType.CONNECTION_STATUS,
+            DataType.BATTERY,
+            DataType.SCAN_MODE,
+            DataType.TEST_DE_BEGIN,
+            DataType.TEST_DE_END
+    })
+    public @interface DataType {
+        int BLE_SERVICE_DATA = 100;
+        int BLE_ADDRESS = 101;
+        // This is to indicate if the scan is offload only
+        int SCAN_MODE = 102;
+        int SALT = 0;
+        int PRIVATE_IDENTITY = 1;
+        int TRUSTED_IDENTITY = 2;
+        int PUBLIC_IDENTITY = 3;
+        int PROVISIONED_IDENTITY = 4;
+        int TX_POWER = 5;
+        int ACTION = 6;
+        int MODEL_ID = 7;
+        int EDDYSTONE_EPHEMERAL_IDENTIFIER = 8;
+        int ACCOUNT_KEY_DATA = 9;
+        int CONNECTION_STATUS = 10;
+        int BATTERY = 11;
+        // Reserves test DE ranges from {@link DataElement.DataType#TEST_DE_BEGIN}
+        // to {@link DataElement.DataType#TEST_DE_END}, inclusive.
+        // Reserves 128 Test DEs.
+        int TEST_DE_BEGIN = Integer.MAX_VALUE - 127; // 2147483520
+        int TEST_DE_END = Integer.MAX_VALUE; // 2147483647
+    }
+
+    /**
+     * @hide
+     */
+    public static boolean isValidType(int type) {
+        return type == DataType.BLE_SERVICE_DATA
+                || type == DataType.ACCOUNT_KEY_DATA
+                || type == DataType.BLE_ADDRESS
+                || type == DataType.SCAN_MODE
+                || type == DataType.SALT
+                || type == DataType.PRIVATE_IDENTITY
+                || type == DataType.TRUSTED_IDENTITY
+                || type == DataType.PUBLIC_IDENTITY
+                || type == DataType.PROVISIONED_IDENTITY
+                || type == DataType.TX_POWER
+                || type == DataType.ACTION
+                || type == DataType.MODEL_ID
+                || type == DataType.EDDYSTONE_EPHEMERAL_IDENTIFIER
+                || type == DataType.CONNECTION_STATUS
+                || type == DataType.BATTERY;
+    }
+
+    /**
+     * @return {@code true} if this is identity type.
+     * @hide
+     */
+    public boolean isIdentityDataType() {
+        return mKey == DataType.PRIVATE_IDENTITY
+                || mKey == DataType.TRUSTED_IDENTITY
+                || mKey == DataType.PUBLIC_IDENTITY
+                || mKey == DataType.PROVISIONED_IDENTITY;
+    }
+
+    /**
+     * @return {@code true} if this is test data element type.
+     * @hide
+     */
+    public static boolean isTestDeType(int type) {
+        return type >= DataType.TEST_DE_BEGIN && type <= DataType.TEST_DE_END;
+    }
+
     /**
      * Constructs a {@link DataElement}.
      */
     public DataElement(int key, @NonNull byte[] value) {
-        Preconditions.checkState(value != null, "value cannot be null");
+        Preconditions.checkArgument(value != null, "value cannot be null");
         mKey = key;
         mValue = value;
     }
@@ -61,6 +149,20 @@
     };
 
     @Override
+    public boolean equals(@Nullable Object obj) {
+        if (obj instanceof DataElement) {
+            return mKey == ((DataElement) obj).mKey
+                    && Arrays.equals(mValue, ((DataElement) obj).mValue);
+        }
+        return false;
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(mKey, Arrays.hashCode(mValue));
+    }
+
+    @Override
     public int describeContents() {
         return 0;
     }
diff --git a/nearby/framework/java/android/nearby/INearbyManager.aidl b/nearby/framework/java/android/nearby/INearbyManager.aidl
index 0291fff..7af271e 100644
--- a/nearby/framework/java/android/nearby/INearbyManager.aidl
+++ b/nearby/framework/java/android/nearby/INearbyManager.aidl
@@ -20,6 +20,7 @@
 import android.nearby.IScanListener;
 import android.nearby.BroadcastRequestParcelable;
 import android.nearby.ScanRequest;
+import android.nearby.aidl.IOffloadCallback;
 
 /**
  * Interface for communicating with the nearby services.
@@ -37,4 +38,6 @@
             in IBroadcastListener callback, String packageName, @nullable String attributionTag);
 
     void stopBroadcast(in IBroadcastListener callback, String packageName, @nullable String attributionTag);
+
+    void queryOffloadCapability(in IOffloadCallback callback) ;
 }
\ No newline at end of file
diff --git a/nearby/framework/java/android/nearby/IScanListener.aidl b/nearby/framework/java/android/nearby/IScanListener.aidl
index 3e3b107..80563b7 100644
--- a/nearby/framework/java/android/nearby/IScanListener.aidl
+++ b/nearby/framework/java/android/nearby/IScanListener.aidl
@@ -34,5 +34,5 @@
         void onLost(in NearbyDeviceParcelable nearbyDeviceParcelable);
 
         /** Reports when there is an error during scanning. */
-        void onError();
+        void onError(in int errorCode);
 }
diff --git a/nearby/framework/java/android/nearby/NearbyDevice.java b/nearby/framework/java/android/nearby/NearbyDevice.java
index 538940c..e8fcc28 100644
--- a/nearby/framework/java/android/nearby/NearbyDevice.java
+++ b/nearby/framework/java/android/nearby/NearbyDevice.java
@@ -21,11 +21,13 @@
 import android.annotation.NonNull;
 import android.annotation.Nullable;
 import android.annotation.SystemApi;
+import android.util.ArraySet;
 
 import com.android.internal.util.Preconditions;
 
 import java.util.List;
 import java.util.Objects;
+import java.util.Set;
 
 /**
  * A class represents a device that can be discovered by multiple mediums.
@@ -123,13 +125,17 @@
 
     @Override
     public boolean equals(Object other) {
-        if (other instanceof NearbyDevice) {
-            NearbyDevice otherDevice = (NearbyDevice) other;
-            return Objects.equals(mName, otherDevice.mName)
-                    && mMediums == otherDevice.mMediums
-                    && mRssi == otherDevice.mRssi;
+        if (!(other instanceof NearbyDevice)) {
+            return false;
         }
-        return false;
+        NearbyDevice otherDevice = (NearbyDevice) other;
+        Set<Integer> mediumSet = new ArraySet<>(mMediums);
+        Set<Integer> otherMediumSet = new ArraySet<>(otherDevice.mMediums);
+        if (!mediumSet.equals(otherMediumSet)) {
+            return false;
+        }
+
+        return Objects.equals(mName, otherDevice.mName) && mRssi == otherDevice.mRssi;
     }
 
     @Override
diff --git a/nearby/framework/java/android/nearby/NearbyDeviceParcelable.java b/nearby/framework/java/android/nearby/NearbyDeviceParcelable.java
index 8f44091..8fb9650 100644
--- a/nearby/framework/java/android/nearby/NearbyDeviceParcelable.java
+++ b/nearby/framework/java/android/nearby/NearbyDeviceParcelable.java
@@ -46,6 +46,7 @@
                 @Override
                 public NearbyDeviceParcelable createFromParcel(Parcel in) {
                     Builder builder = new Builder();
+                    builder.setDeviceId(in.readLong());
                     builder.setScanType(in.readInt());
                     if (in.readInt() == 1) {
                         builder.setName(in.readString());
@@ -76,6 +77,17 @@
                         in.readByteArray(salt);
                         builder.setData(salt);
                     }
+                    if (in.readInt() == 1) {
+                        builder.setPresenceDevice(in.readParcelable(
+                                PresenceDevice.class.getClassLoader(),
+                                PresenceDevice.class));
+                    }
+                    if (in.readInt() == 1) {
+                        int encryptionKeyTagLength = in.readInt();
+                        byte[] keyTag = new byte[encryptionKeyTagLength];
+                        in.readByteArray(keyTag);
+                        builder.setData(keyTag);
+                    }
                     return builder.build();
                 }
 
@@ -85,6 +97,7 @@
                 }
             };
 
+    private final long mDeviceId;
     @ScanRequest.ScanType int mScanType;
     @Nullable private final String mName;
     @NearbyDevice.Medium private final int mMedium;
@@ -96,8 +109,11 @@
     @Nullable private final String mFastPairModelId;
     @Nullable private final byte[] mData;
     @Nullable private final byte[] mSalt;
+    @Nullable private final PresenceDevice mPresenceDevice;
+    @Nullable private final byte[] mEncryptionKeyTag;
 
     private NearbyDeviceParcelable(
+            long deviceId,
             @ScanRequest.ScanType int scanType,
             @Nullable String name,
             int medium,
@@ -108,7 +124,10 @@
             @Nullable String fastPairModelId,
             @Nullable String bluetoothAddress,
             @Nullable byte[] data,
-            @Nullable byte[] salt) {
+            @Nullable byte[] salt,
+            @Nullable PresenceDevice presenceDevice,
+            @Nullable byte[] encryptionKeyTag) {
+        mDeviceId = deviceId;
         mScanType = scanType;
         mName = name;
         mMedium = medium;
@@ -120,6 +139,8 @@
         mBluetoothAddress = bluetoothAddress;
         mData = data;
         mSalt = salt;
+        mPresenceDevice = presenceDevice;
+        mEncryptionKeyTag = encryptionKeyTag;
     }
 
     /** No special parcel contents. */
@@ -136,6 +157,7 @@
      */
     @Override
     public void writeToParcel(@NonNull Parcel dest, int flags) {
+        dest.writeLong(mDeviceId);
         dest.writeInt(mScanType);
         dest.writeInt(mName == null ? 0 : 1);
         if (mName != null) {
@@ -164,13 +186,24 @@
             dest.writeInt(mSalt.length);
             dest.writeByteArray(mSalt);
         }
+        dest.writeInt(mPresenceDevice == null ? 0 : 1);
+        if (mPresenceDevice != null) {
+            dest.writeParcelable(mPresenceDevice, /* parcelableFlags= */ 0);
+        }
+        dest.writeInt(mEncryptionKeyTag == null ? 0 : 1);
+        if (mEncryptionKeyTag != null) {
+            dest.writeInt(mEncryptionKeyTag.length);
+            dest.writeByteArray(mEncryptionKeyTag);
+        }
     }
 
     /** Returns a string representation of this ScanRequest. */
     @Override
     public String toString() {
         return "NearbyDeviceParcelable["
-                + "scanType="
+                + "deviceId="
+                + mDeviceId
+                + ", scanType="
                 + mScanType
                 + ", name="
                 + mName
@@ -197,20 +230,25 @@
     public boolean equals(Object other) {
         if (other instanceof NearbyDeviceParcelable) {
             NearbyDeviceParcelable otherNearbyDeviceParcelable = (NearbyDeviceParcelable) other;
-            return mScanType == otherNearbyDeviceParcelable.mScanType
+            return  mDeviceId == otherNearbyDeviceParcelable.mDeviceId
+                    && mScanType == otherNearbyDeviceParcelable.mScanType
                     && (Objects.equals(mName, otherNearbyDeviceParcelable.mName))
                     && (mMedium == otherNearbyDeviceParcelable.mMedium)
                     && (mTxPower == otherNearbyDeviceParcelable.mTxPower)
                     && (mRssi == otherNearbyDeviceParcelable.mRssi)
                     && (mAction == otherNearbyDeviceParcelable.mAction)
                     && (Objects.equals(
-                            mPublicCredential, otherNearbyDeviceParcelable.mPublicCredential))
+                    mPublicCredential, otherNearbyDeviceParcelable.mPublicCredential))
                     && (Objects.equals(
-                            mBluetoothAddress, otherNearbyDeviceParcelable.mBluetoothAddress))
+                    mBluetoothAddress, otherNearbyDeviceParcelable.mBluetoothAddress))
                     && (Objects.equals(
-                            mFastPairModelId, otherNearbyDeviceParcelable.mFastPairModelId))
+                    mFastPairModelId, otherNearbyDeviceParcelable.mFastPairModelId))
                     && (Arrays.equals(mData, otherNearbyDeviceParcelable.mData))
-                    && (Arrays.equals(mSalt, otherNearbyDeviceParcelable.mSalt));
+                    && (Arrays.equals(mSalt, otherNearbyDeviceParcelable.mSalt))
+                    && (Objects.equals(
+                    mPresenceDevice, otherNearbyDeviceParcelable.mPresenceDevice))
+                    && (Arrays.equals(
+                    mEncryptionKeyTag, otherNearbyDeviceParcelable.mEncryptionKeyTag));
         }
         return false;
     }
@@ -218,6 +256,7 @@
     @Override
     public int hashCode() {
         return Objects.hash(
+                mDeviceId,
                 mScanType,
                 mName,
                 mMedium,
@@ -227,7 +266,19 @@
                 mBluetoothAddress,
                 mFastPairModelId,
                 Arrays.hashCode(mData),
-                Arrays.hashCode(mSalt));
+                Arrays.hashCode(mSalt),
+                mPresenceDevice,
+                Arrays.hashCode(mEncryptionKeyTag));
+    }
+
+    /**
+     * The id of the device.
+     * <p>This id is not a hardware id. It may rotate based on the remote device's broadcasts.
+     *
+     * @hide
+     */
+    public long getDeviceId() {
+        return mDeviceId;
     }
 
     /**
@@ -351,8 +402,29 @@
         return mSalt;
     }
 
+    /**
+     * Gets the {@link PresenceDevice} Nearby Presence device. This field is
+     * for Fast Pair client only.
+     */
+    @Nullable
+    public PresenceDevice getPresenceDevice() {
+        return mPresenceDevice;
+    }
+
+    /**
+     * Gets the encryption key tag calculated from advertisement
+     * Returns {@code null} if the data is not encrypted or this is not a Presence device.
+     *
+     * Used in Presence.
+     */
+    @Nullable
+    public byte[] getEncryptionKeyTag() {
+        return mEncryptionKeyTag;
+    }
+
     /** Builder class for {@link NearbyDeviceParcelable}. */
     public static final class Builder {
+        private long mDeviceId = -1;
         @Nullable private String mName;
         @NearbyDevice.Medium private int mMedium;
         private int mTxPower;
@@ -364,6 +436,14 @@
         @Nullable private String mBluetoothAddress;
         @Nullable private byte[] mData;
         @Nullable private byte[] mSalt;
+        @Nullable private PresenceDevice mPresenceDevice;
+        @Nullable private byte[] mEncryptionKeyTag;
+
+        /** Sets the id of the device. */
+        public Builder setDeviceId(long deviceId) {
+            this.mDeviceId = deviceId;
+            return this;
+        }
 
         /**
          * Sets the scan type of the NearbyDeviceParcelable.
@@ -469,7 +549,7 @@
         /**
          * Sets the scanned raw data.
          *
-         * @param data Data the scan. For example, {@link ScanRecord#getServiceData()} if scanned by
+         * @param data raw data scanned, like {@link ScanRecord#getServiceData()} if scanned by
          *             Bluetooth.
          */
         @NonNull
@@ -479,6 +559,17 @@
         }
 
         /**
+         * Sets the encryption key tag calculated from the advertisement.
+         *
+         * @param encryptionKeyTag calculated from identity scanned from the advertisement
+         */
+        @NonNull
+        public Builder setEncryptionKeyTag(@Nullable byte[] encryptionKeyTag) {
+            mEncryptionKeyTag = encryptionKeyTag;
+            return this;
+        }
+
+        /**
          * Sets the slat in the advertisement from the Nearby Presence device.
          *
          * @param salt in the advertisement from the Nearby Presence device.
@@ -489,10 +580,22 @@
             return this;
         }
 
+        /**
+         * Sets the {@link PresenceDevice} if there is any.
+         * The current {@link NearbyDeviceParcelable} can be seen as the wrapper of the
+         * {@link PresenceDevice}.
+         */
+        @Nullable
+        public Builder setPresenceDevice(@Nullable PresenceDevice presenceDevice) {
+            mPresenceDevice = presenceDevice;
+            return this;
+        }
+
         /** Builds a ScanResult. */
         @NonNull
         public NearbyDeviceParcelable build() {
             return new NearbyDeviceParcelable(
+                    mDeviceId,
                     mScanType,
                     mName,
                     mMedium,
@@ -503,7 +606,9 @@
                     mFastPairModelId,
                     mBluetoothAddress,
                     mData,
-                    mSalt);
+                    mSalt,
+                    mPresenceDevice,
+                    mEncryptionKeyTag);
         }
     }
 }
diff --git a/nearby/framework/java/android/nearby/NearbyManager.java b/nearby/framework/java/android/nearby/NearbyManager.java
index 106c290..a70b303 100644
--- a/nearby/framework/java/android/nearby/NearbyManager.java
+++ b/nearby/framework/java/android/nearby/NearbyManager.java
@@ -26,6 +26,7 @@
 import android.annotation.SystemApi;
 import android.annotation.SystemService;
 import android.content.Context;
+import android.nearby.aidl.IOffloadCallback;
 import android.os.RemoteException;
 import android.provider.Settings;
 import android.util.Log;
@@ -37,6 +38,7 @@
 import java.util.Objects;
 import java.util.WeakHashMap;
 import java.util.concurrent.Executor;
+import java.util.function.Consumer;
 
 /**
  * This class provides a way to perform Nearby related operations such as scanning, broadcasting
@@ -62,7 +64,7 @@
             ScanStatus.ERROR,
     })
     public @interface ScanStatus {
-        // Default, invalid state.
+        // The undetermined status, some modules may be initializing. Retry is suggested.
         int UNKNOWN = 0;
         // The successful state.
         int SUCCESS = 1;
@@ -73,6 +75,7 @@
     private static final String TAG = "NearbyManager";
 
     /**
+     * TODO(b/286137024): Remove this when CTS R5 is rolled out.
      * Whether allows Fast Pair to scan.
      *
      * (0 = disabled, 1 = enabled)
@@ -103,6 +106,9 @@
         mService = service;
     }
 
+    // This can be null when NearbyDeviceParcelable field not set for Presence device
+    // or the scan type is not recognized.
+    @Nullable
     private static NearbyDevice toClientNearbyDevice(
             NearbyDeviceParcelable nearbyDeviceParcelable,
             @ScanRequest.ScanType int scanType) {
@@ -118,23 +124,12 @@
         }
 
         if (scanType == ScanRequest.SCAN_TYPE_NEARBY_PRESENCE) {
-            PublicCredential publicCredential = nearbyDeviceParcelable.getPublicCredential();
-            if (publicCredential == null) {
-                return null;
+            PresenceDevice presenceDevice = nearbyDeviceParcelable.getPresenceDevice();
+            if (presenceDevice == null) {
+                Log.e(TAG,
+                        "Cannot find any Presence device in discovered NearbyDeviceParcelable");
             }
-            byte[] salt = nearbyDeviceParcelable.getSalt();
-            if (salt == null) {
-                salt = new byte[0];
-            }
-            return new PresenceDevice.Builder(
-                    // Use the public credential hash as the device Id.
-                    String.valueOf(publicCredential.hashCode()),
-                    salt,
-                    publicCredential.getSecretId(),
-                    publicCredential.getEncryptedMetadata())
-                    .setRssi(nearbyDeviceParcelable.getRssi())
-                    .addMedium(nearbyDeviceParcelable.getMedium())
-                    .build();
+            return presenceDevice;
         }
         return null;
     }
@@ -278,29 +273,42 @@
     }
 
     /**
-     * Read from {@link Settings} whether Fast Pair scan is enabled.
+     * Query offload capability in a device. The query is asynchronous and result is called back
+     * in {@link Consumer}, which is set to true if offload is supported.
      *
-     * @param context the {@link Context} to query the setting
-     * @return whether the Fast Pair is enabled
-     * @hide
+     * @param executor the callback will take place on this {@link Executor}
+     * @param callback the callback invoked with {@link OffloadCapability}
      */
-    public static boolean getFastPairScanEnabled(@NonNull Context context) {
-        final int enabled = Settings.Secure.getInt(
-                context.getContentResolver(), FAST_PAIR_SCAN_ENABLED, 0);
-        return enabled != 0;
+    public void queryOffloadCapability(@NonNull @CallbackExecutor Executor executor,
+            @NonNull Consumer<OffloadCapability> callback) {
+        try {
+            mService.queryOffloadCapability(new OffloadTransport(executor, callback));
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
     }
 
-    /**
-     * Write into {@link Settings} whether Fast Pair scan is enabled
-     *
-     * @param context the {@link Context} to set the setting
-     * @param enable whether the Fast Pair scan should be enabled
-     * @hide
-     */
-    @RequiresPermission(Manifest.permission.WRITE_SECURE_SETTINGS)
-    public static void setFastPairScanEnabled(@NonNull Context context, boolean enable) {
-        Settings.Secure.putInt(
-                context.getContentResolver(), FAST_PAIR_SCAN_ENABLED, enable ? 1 : 0);
+    private static class OffloadTransport extends IOffloadCallback.Stub {
+
+        private final Executor mExecutor;
+        // Null when cancelled
+        volatile @Nullable Consumer<OffloadCapability> mConsumer;
+
+        OffloadTransport(Executor executor, Consumer<OffloadCapability> consumer) {
+            Preconditions.checkArgument(executor != null, "illegal null executor");
+            Preconditions.checkArgument(consumer != null, "illegal null consumer");
+            mExecutor = executor;
+            mConsumer = consumer;
+        }
+
+        @Override
+        public void onQueryComplete(OffloadCapability capability) {
+            mExecutor.execute(() -> {
+                if (mConsumer != null) {
+                    mConsumer.accept(capability);
+                }
+            });
+        }
     }
 
     private static class ScanListenerTransport extends IScanListener.Stub {
@@ -339,9 +347,9 @@
         public void onDiscovered(NearbyDeviceParcelable nearbyDeviceParcelable)
                 throws RemoteException {
             mExecutor.execute(() -> {
-                if (mScanCallback != null) {
-                    mScanCallback.onDiscovered(
-                            toClientNearbyDevice(nearbyDeviceParcelable, mScanType));
+                NearbyDevice nearbyDevice = toClientNearbyDevice(nearbyDeviceParcelable, mScanType);
+                if (mScanCallback != null && nearbyDevice != null) {
+                    mScanCallback.onDiscovered(nearbyDevice);
                 }
             });
         }
@@ -350,7 +358,8 @@
         public void onUpdated(NearbyDeviceParcelable nearbyDeviceParcelable)
                 throws RemoteException {
             mExecutor.execute(() -> {
-                if (mScanCallback != null) {
+                NearbyDevice nearbyDevice = toClientNearbyDevice(nearbyDeviceParcelable, mScanType);
+                if (mScanCallback != null && nearbyDevice != null) {
                     mScanCallback.onUpdated(
                             toClientNearbyDevice(nearbyDeviceParcelable, mScanType));
                 }
@@ -360,7 +369,8 @@
         @Override
         public void onLost(NearbyDeviceParcelable nearbyDeviceParcelable) throws RemoteException {
             mExecutor.execute(() -> {
-                if (mScanCallback != null) {
+                NearbyDevice nearbyDevice = toClientNearbyDevice(nearbyDeviceParcelable, mScanType);
+                if (mScanCallback != null && nearbyDevice != null) {
                     mScanCallback.onLost(
                             toClientNearbyDevice(nearbyDeviceParcelable, mScanType));
                 }
@@ -368,10 +378,10 @@
         }
 
         @Override
-        public void onError() {
+        public void onError(int errorCode) {
             mExecutor.execute(() -> {
                 if (mScanCallback != null) {
-                    Log.e("NearbyManager", "onError: There is an error in scan.");
+                    mScanCallback.onError(errorCode);
                 }
             });
         }
@@ -410,4 +420,35 @@
             });
         }
     }
+
+    /**
+     * TODO(b/286137024): Remove this when CTS R5 is rolled out.
+     * Read from {@link Settings} whether Fast Pair scan is enabled.
+     *
+     * @param context the {@link Context} to query the setting
+     * @return whether the Fast Pair is enabled
+     * @hide
+     */
+    public static boolean getFastPairScanEnabled(@NonNull Context context) {
+        final int enabled = Settings.Secure.getInt(
+                context.getContentResolver(), FAST_PAIR_SCAN_ENABLED, 0);
+        return enabled != 0;
+    }
+
+    /**
+     * TODO(b/286137024): Remove this when CTS R5 is rolled out.
+     * Write into {@link Settings} whether Fast Pair scan is enabled
+     *
+     * @param context the {@link Context} to set the setting
+     * @param enable whether the Fast Pair scan should be enabled
+     * @hide
+     */
+    @RequiresPermission(Manifest.permission.WRITE_SECURE_SETTINGS)
+    public static void setFastPairScanEnabled(@NonNull Context context, boolean enable) {
+        Settings.Secure.putInt(
+                context.getContentResolver(), FAST_PAIR_SCAN_ENABLED, enable ? 1 : 0);
+        Log.v(TAG, String.format(
+                "successfully %s Fast Pair scan", enable ? "enables" : "disables"));
+    }
+
 }
diff --git a/nearby/framework/java/android/nearby/OffloadCapability.aidl b/nearby/framework/java/android/nearby/OffloadCapability.aidl
new file mode 100644
index 0000000..fe1c45e
--- /dev/null
+++ b/nearby/framework/java/android/nearby/OffloadCapability.aidl
@@ -0,0 +1,25 @@
+/*
+ * 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.nearby;
+
+/**
+ * A class that can describe what offload functions are available.
+ *
+ * {@hide}
+ */
+parcelable OffloadCapability;
+
diff --git a/nearby/framework/java/android/nearby/OffloadCapability.java b/nearby/framework/java/android/nearby/OffloadCapability.java
new file mode 100644
index 0000000..9071c1c
--- /dev/null
+++ b/nearby/framework/java/android/nearby/OffloadCapability.java
@@ -0,0 +1,162 @@
+/*
+ * 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.nearby;
+
+import android.annotation.NonNull;
+import android.annotation.SystemApi;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import java.util.Objects;
+
+/**
+ * A class that can describe what offload functions are available.
+ *
+ * @hide
+ */
+@SystemApi
+public final class OffloadCapability implements Parcelable {
+    private final boolean mFastPairSupported;
+    private final boolean mNearbyShareSupported;
+    private final long mVersion;
+
+    public boolean isFastPairSupported() {
+        return mFastPairSupported;
+    }
+
+    public boolean isNearbyShareSupported() {
+        return mNearbyShareSupported;
+    }
+
+    public long getVersion() {
+        return mVersion;
+    }
+
+
+    @Override
+    public void writeToParcel(@NonNull Parcel dest, int flags) {
+        dest.writeBoolean(mFastPairSupported);
+        dest.writeBoolean(mNearbyShareSupported);
+        dest.writeLong(mVersion);
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @NonNull
+    public static final Creator<OffloadCapability> CREATOR = new Creator<OffloadCapability>() {
+        @Override
+        public OffloadCapability createFromParcel(Parcel in) {
+            boolean isFastPairSupported = in.readBoolean();
+            boolean isNearbyShareSupported = in.readBoolean();
+            long version = in.readLong();
+            return new Builder()
+                    .setFastPairSupported(isFastPairSupported)
+                    .setNearbyShareSupported(isNearbyShareSupported)
+                    .setVersion(version)
+                    .build();
+        }
+
+        @Override
+        public OffloadCapability[] newArray(int size) {
+            return new OffloadCapability[size];
+        }
+    };
+
+    private OffloadCapability(boolean fastPairSupported, boolean nearbyShareSupported,
+            long version) {
+        mFastPairSupported = fastPairSupported;
+        mNearbyShareSupported = nearbyShareSupported;
+        mVersion = version;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (!(o instanceof OffloadCapability)) return false;
+        OffloadCapability that = (OffloadCapability) o;
+        return isFastPairSupported() == that.isFastPairSupported()
+                && isNearbyShareSupported() == that.isNearbyShareSupported()
+                && getVersion() == that.getVersion();
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(isFastPairSupported(), isNearbyShareSupported(), getVersion());
+    }
+
+    @Override
+    public String toString() {
+        return "OffloadCapability{"
+                + "fastPairSupported=" + mFastPairSupported
+                + ", nearbyShareSupported=" + mNearbyShareSupported
+                + ", version=" + mVersion
+                + '}';
+    }
+
+    /**
+     * Builder class for {@link OffloadCapability}.
+     */
+    public static final class Builder {
+        private boolean mFastPairSupported;
+        private boolean mNearbyShareSupported;
+        private long mVersion;
+
+        /**
+         * Sets if the Nearby Share feature is supported
+         *
+         * @param fastPairSupported {@code true} if the Fast Pair feature is supported
+         */
+        @NonNull
+        public Builder setFastPairSupported(boolean fastPairSupported) {
+            mFastPairSupported = fastPairSupported;
+            return this;
+        }
+
+        /**
+         * Sets if the Nearby Share feature is supported.
+         *
+         * @param nearbyShareSupported {@code true} if the Nearby Share feature is supported
+         */
+        @NonNull
+        public Builder setNearbyShareSupported(boolean nearbyShareSupported) {
+            mNearbyShareSupported = nearbyShareSupported;
+            return this;
+        }
+
+        /**
+         * Sets the version number of Nearby Offload.
+         *
+         * @param version Nearby Offload version number
+         */
+        @NonNull
+        public Builder setVersion(long version) {
+            mVersion = version;
+            return this;
+        }
+
+        /**
+         * Builds an OffloadCapability object.
+         */
+        @NonNull
+        public OffloadCapability build() {
+            return new OffloadCapability(mFastPairSupported, mNearbyShareSupported, mVersion);
+        }
+    }
+}
diff --git a/nearby/framework/java/android/nearby/PresenceDevice.java b/nearby/framework/java/android/nearby/PresenceDevice.java
index cb406e4..b5d9ad4 100644
--- a/nearby/framework/java/android/nearby/PresenceDevice.java
+++ b/nearby/framework/java/android/nearby/PresenceDevice.java
@@ -26,6 +26,7 @@
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.List;
 import java.util.Objects;
 
@@ -134,6 +135,54 @@
         return mExtendedProperties;
     }
 
+    /**
+     * This can only be hidden because this is the System API,
+     * which cannot be changed in T timeline.
+     *
+     * @hide
+     */
+    @Override
+    public boolean equals(Object other) {
+        if (other instanceof PresenceDevice) {
+            PresenceDevice otherDevice = (PresenceDevice) other;
+            if (super.equals(otherDevice)) {
+                return Arrays.equals(mSalt, otherDevice.mSalt)
+                        && Arrays.equals(mSecretId, otherDevice.mSecretId)
+                        && Arrays.equals(mEncryptedIdentity, otherDevice.mEncryptedIdentity)
+                        && Objects.equals(mDeviceId, otherDevice.mDeviceId)
+                        && mDeviceType == otherDevice.mDeviceType
+                        && Objects.equals(mDeviceImageUrl, otherDevice.mDeviceImageUrl)
+                        && mDiscoveryTimestampMillis == otherDevice.mDiscoveryTimestampMillis
+                        && Objects.equals(mExtendedProperties, otherDevice.mExtendedProperties);
+            }
+        }
+        return false;
+    }
+
+    /**
+     * This can only be hidden because this is the System API,
+     * which cannot be changed in T timeline.
+     *
+     * @hide
+     *
+     * @return The unique hash value of the {@link PresenceDevice}
+     */
+    @Override
+    public int hashCode() {
+        return Objects.hash(
+                getName(),
+                getMediums(),
+                getRssi(),
+                Arrays.hashCode(mSalt),
+                Arrays.hashCode(mSecretId),
+                Arrays.hashCode(mEncryptedIdentity),
+                mDeviceId,
+                mDeviceType,
+                mDeviceImageUrl,
+                mDiscoveryTimestampMillis,
+                mExtendedProperties);
+    }
+
     private PresenceDevice(String deviceName, List<Integer> mMediums, int rssi, String deviceId,
             byte[] salt, byte[] secretId, byte[] encryptedIdentity, int deviceType,
             String deviceImageUrl, long discoveryTimestampMillis,
@@ -326,7 +375,6 @@
             return this;
         }
 
-
         /**
          * Sets the image url of the discovered Presence device.
          *
@@ -338,7 +386,6 @@
             return this;
         }
 
-
         /**
          * Sets discovery timestamp, the clock is based on elapsed time.
          *
@@ -350,7 +397,6 @@
             return this;
         }
 
-
         /**
          * Adds an extended property of the discovered presence device.
          *
diff --git a/nearby/framework/java/android/nearby/PresenceScanFilter.java b/nearby/framework/java/android/nearby/PresenceScanFilter.java
index f0c3c06..50e97b4 100644
--- a/nearby/framework/java/android/nearby/PresenceScanFilter.java
+++ b/nearby/framework/java/android/nearby/PresenceScanFilter.java
@@ -71,7 +71,7 @@
         super(ScanRequest.SCAN_TYPE_NEARBY_PRESENCE, rssiThreshold);
         mCredentials = new ArrayList<>(credentials);
         mPresenceActions = new ArrayList<>(presenceActions);
-        mExtendedProperties = extendedProperties;
+        mExtendedProperties = new ArrayList<>(extendedProperties);
     }
 
     private PresenceScanFilter(Parcel in) {
@@ -132,7 +132,7 @@
         }
         dest.writeInt(mExtendedProperties.size());
         if (!mExtendedProperties.isEmpty()) {
-            dest.writeList(mExtendedProperties);
+            dest.writeParcelableList(mExtendedProperties, 0);
         }
     }
 
diff --git a/nearby/framework/java/android/nearby/ScanCallback.java b/nearby/framework/java/android/nearby/ScanCallback.java
index 1b1b4bc..7b66607 100644
--- a/nearby/framework/java/android/nearby/ScanCallback.java
+++ b/nearby/framework/java/android/nearby/ScanCallback.java
@@ -16,9 +16,13 @@
 
 package android.nearby;
 
+import android.annotation.IntDef;
 import android.annotation.NonNull;
 import android.annotation.SystemApi;
 
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
 /**
  * Reports newly discovered devices.
  * Note: The frequency of the callback is dependent on whether the caller
@@ -31,6 +35,37 @@
  */
 @SystemApi
 public interface ScanCallback {
+
+    /** General error code for scan. */
+    int ERROR_UNKNOWN = 0;
+
+    /**
+     * Scan failed as the request is not supported.
+     */
+    int ERROR_UNSUPPORTED = 1;
+
+    /**
+     * Invalid argument such as out-of-range, illegal format etc.
+     */
+    int ERROR_INVALID_ARGUMENT = 2;
+
+    /**
+     * Request from clients who do not have permissions.
+     */
+    int ERROR_PERMISSION_DENIED = 3;
+
+    /**
+     * Request cannot be fulfilled due to limited resource.
+     */
+    int ERROR_RESOURCE_EXHAUSTED = 4;
+
+    /** @hide **/
+    @Retention(RetentionPolicy.SOURCE)
+    @IntDef({ERROR_UNKNOWN, ERROR_UNSUPPORTED, ERROR_INVALID_ARGUMENT, ERROR_PERMISSION_DENIED,
+            ERROR_RESOURCE_EXHAUSTED})
+    @interface ErrorCode {
+    }
+
     /**
      * Reports a {@link NearbyDevice} being discovered.
      *
@@ -51,4 +86,11 @@
      * @param device {@link NearbyDevice} that is lost.
      */
     void onLost(@NonNull NearbyDevice device);
+
+    /**
+     * Notifies clients of error from the scan.
+     *
+     * @param errorCode defined by Nearby
+     */
+    default void onError(@ErrorCode int errorCode) {}
 }
diff --git a/nearby/framework/java/android/nearby/ScanRequest.java b/nearby/framework/java/android/nearby/ScanRequest.java
index c717ac7..61cbf39 100644
--- a/nearby/framework/java/android/nearby/ScanRequest.java
+++ b/nearby/framework/java/android/nearby/ScanRequest.java
@@ -22,6 +22,7 @@
 import android.annotation.Nullable;
 import android.annotation.RequiresPermission;
 import android.annotation.SystemApi;
+import android.os.Build;
 import android.os.Parcel;
 import android.os.Parcelable;
 import android.os.WorkSource;
@@ -33,6 +34,8 @@
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Objects;
+import java.util.concurrent.Executor;
+import java.util.function.Consumer;
 
 /**
  * An encapsulation of various parameters for requesting nearby scans.
@@ -62,6 +65,12 @@
      */
     public static final int SCAN_MODE_NO_POWER = -1;
     /**
+     * A special scan mode to indicate that client only wants to use CHRE to scan.
+     *
+     * @hide
+     */
+    public static final int SCAN_MODE_CHRE_ONLY = 3;
+    /**
      * Used to read a ScanRequest from a Parcel.
      */
     @NonNull
@@ -72,6 +81,7 @@
                     .setScanType(in.readInt())
                     .setScanMode(in.readInt())
                     .setBleEnabled(in.readBoolean())
+                    .setOffloadOnly(in.readBoolean())
                     .setWorkSource(in.readTypedObject(WorkSource.CREATOR));
             final int size = in.readInt();
             for (int i = 0; i < size; i++) {
@@ -89,14 +99,16 @@
     private final @ScanType int mScanType;
     private final @ScanMode int mScanMode;
     private final boolean mBleEnabled;
+    private final boolean mOffloadOnly;
     private final @NonNull WorkSource mWorkSource;
     private final List<ScanFilter> mScanFilters;
 
     private ScanRequest(@ScanType int scanType, @ScanMode int scanMode, boolean bleEnabled,
-            @NonNull WorkSource workSource, List<ScanFilter> scanFilters) {
+            boolean offloadOnly, @NonNull WorkSource workSource, List<ScanFilter> scanFilters) {
         mScanType = scanType;
         mScanMode = scanMode;
         mBleEnabled = bleEnabled;
+        mOffloadOnly = offloadOnly;
         mWorkSource = workSource;
         mScanFilters = scanFilters;
     }
@@ -162,6 +174,13 @@
     }
 
     /**
+     * Returns if CHRE enabled for scanning.
+     */
+    public boolean isOffloadOnly() {
+        return mOffloadOnly;
+    }
+
+    /**
      * Returns Scan Filters for this request.
      */
     @NonNull
@@ -197,7 +216,13 @@
         stringBuilder.append("Request[")
                 .append("scanType=").append(mScanType);
         stringBuilder.append(", scanMode=").append(scanModeToString(mScanMode));
-        stringBuilder.append(", enableBle=").append(mBleEnabled);
+        // TODO(b/286137024): Remove this when CTS R5 is rolled out.
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+            stringBuilder.append(", bleEnabled=").append(mBleEnabled);
+            stringBuilder.append(", offloadOnly=").append(mOffloadOnly);
+        } else {
+            stringBuilder.append(", enableBle=").append(mBleEnabled);
+        }
         stringBuilder.append(", workSource=").append(mWorkSource);
         stringBuilder.append(", scanFilters=").append(mScanFilters);
         stringBuilder.append("]");
@@ -209,6 +234,7 @@
         dest.writeInt(mScanType);
         dest.writeInt(mScanMode);
         dest.writeBoolean(mBleEnabled);
+        dest.writeBoolean(mOffloadOnly);
         dest.writeTypedObject(mWorkSource, /* parcelableFlags= */0);
         final int size = mScanFilters.size();
         dest.writeInt(size);
@@ -224,6 +250,7 @@
             return mScanType == otherRequest.mScanType
                     && (mScanMode == otherRequest.mScanMode)
                     && (mBleEnabled == otherRequest.mBleEnabled)
+                    && (mOffloadOnly == otherRequest.mOffloadOnly)
                     && (Objects.equals(mWorkSource, otherRequest.mWorkSource));
         }
         return false;
@@ -231,7 +258,7 @@
 
     @Override
     public int hashCode() {
-        return Objects.hash(mScanType, mScanMode, mBleEnabled, mWorkSource);
+        return Objects.hash(mScanType, mScanMode, mBleEnabled, mOffloadOnly, mWorkSource);
     }
 
     /** @hide **/
@@ -254,6 +281,7 @@
         private @ScanMode int mScanMode;
 
         private boolean mBleEnabled;
+        private boolean mOffloadOnly;
         private WorkSource mWorkSource;
         private List<ScanFilter> mScanFilters;
 
@@ -261,6 +289,7 @@
         public Builder() {
             mScanType = INVALID_SCAN_TYPE;
             mBleEnabled = true;
+            mOffloadOnly = false;
             mWorkSource = new WorkSource();
             mScanFilters = new ArrayList<>();
         }
@@ -301,6 +330,22 @@
         }
 
         /**
+         * By default, a scan request can be served by either offload or
+         * non-offload implementation, depending on the resource available in the device.
+         *
+         * A client can explicitly request a scan to be served by offload only.
+         * Before the request, the client should query the offload capability by
+         * using {@link NearbyManager#queryOffloadCapability(Executor, Consumer)}}. Otherwise,
+         * {@link ScanCallback#ERROR_UNSUPPORTED} will be returned on devices without
+         * offload capability.
+         */
+        @NonNull
+        public Builder setOffloadOnly(boolean offloadOnly) {
+            mOffloadOnly = offloadOnly;
+            return this;
+        }
+
+        /**
          * Sets the work source to use for power attribution for this scan request. Defaults to
          * empty work source, which implies the caller that sends the scan request will be used
          * for power attribution.
@@ -355,7 +400,8 @@
             Preconditions.checkState(isValidScanMode(mScanMode),
                     "invalid scan mode : " + mScanMode
                             + ", scan mode must be one of ScanMode#SCAN_MODE_");
-            return new ScanRequest(mScanType, mScanMode, mBleEnabled, mWorkSource, mScanFilters);
+            return new ScanRequest(
+                    mScanType, mScanMode, mBleEnabled, mOffloadOnly, mWorkSource, mScanFilters);
         }
     }
 }
diff --git a/nearby/framework/java/android/nearby/aidl/IOffloadCallback.aidl b/nearby/framework/java/android/nearby/aidl/IOffloadCallback.aidl
new file mode 100644
index 0000000..8bef817
--- /dev/null
+++ b/nearby/framework/java/android/nearby/aidl/IOffloadCallback.aidl
@@ -0,0 +1,29 @@
+/*
+ * 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.nearby.aidl;
+
+import android.nearby.OffloadCapability;
+
+/**
+ * Listener for offload queries.
+ *
+ * {@hide}
+ */
+oneway interface IOffloadCallback {
+        /** Invokes when ContextHub transaction completes. */
+        void onQueryComplete(in OffloadCapability capability);
+}
diff --git a/nearby/service/java/com/android/server/nearby/NearbyConfiguration.java b/nearby/service/java/com/android/server/nearby/NearbyConfiguration.java
index 8fdac87..9b32d69 100644
--- a/nearby/service/java/com/android/server/nearby/NearbyConfiguration.java
+++ b/nearby/service/java/com/android/server/nearby/NearbyConfiguration.java
@@ -16,9 +16,16 @@
 
 package com.android.server.nearby;
 
+import android.os.Build;
 import android.provider.DeviceConfig;
 
-import androidx.annotation.VisibleForTesting;
+import androidx.annotation.NonNull;
+
+import com.android.internal.annotations.GuardedBy;
+import com.android.modules.utils.build.SdkLevel;
+import com.android.server.nearby.managers.DiscoveryProviderManager;
+
+import java.util.concurrent.Executors;
 
 /**
  * A utility class for encapsulating Nearby feature flag configurations.
@@ -26,33 +33,123 @@
 public class NearbyConfiguration {
 
     /**
-     * Flag use to enable presence legacy broadcast.
+     * Flag used to enable presence legacy broadcast.
      */
     public static final String NEARBY_ENABLE_PRESENCE_BROADCAST_LEGACY =
             "nearby_enable_presence_broadcast_legacy";
+    /**
+     * Flag used to for minimum nano app version to make Nearby CHRE scan work.
+     */
+    public static final String NEARBY_MAINLINE_NANO_APP_MIN_VERSION =
+            "nearby_mainline_nano_app_min_version";
 
+    /**
+     * Flag used to allow test mode and customization.
+     */
+    public static final String NEARBY_SUPPORT_TEST_APP = "nearby_support_test_app";
+
+    /**
+     * Flag to control which version of DiscoveryProviderManager should be used.
+     */
+    public static final String NEARBY_REFACTOR_DISCOVERY_MANAGER =
+            "nearby_refactor_discovery_manager";
+
+    private static final boolean IS_USER_BUILD = "user".equals(Build.TYPE);
+
+    private final DeviceConfigListener mDeviceConfigListener = new DeviceConfigListener();
+    private final Object mDeviceConfigLock = new Object();
+
+    @GuardedBy("mDeviceConfigLock")
     private boolean mEnablePresenceBroadcastLegacy;
+    @GuardedBy("mDeviceConfigLock")
+    private int mNanoAppMinVersion;
+    @GuardedBy("mDeviceConfigLock")
+    private boolean mSupportTestApp;
+    @GuardedBy("mDeviceConfigLock")
+    private boolean mRefactorDiscoveryManager;
 
     public NearbyConfiguration() {
-        mEnablePresenceBroadcastLegacy = getDeviceConfigBoolean(
-                NEARBY_ENABLE_PRESENCE_BROADCAST_LEGACY, false /* defaultValue */);
+        mDeviceConfigListener.start();
+    }
 
+    /**
+     * Returns the DeviceConfig namespace for Nearby. The {@link DeviceConfig#NAMESPACE_NEARBY} was
+     * added in UpsideDownCake, in Tiramisu, we use {@link DeviceConfig#NAMESPACE_TETHERING}.
+     */
+    public static String getNamespace() {
+        if (SdkLevel.isAtLeastU()) {
+            return DeviceConfig.NAMESPACE_NEARBY;
+        }
+        return DeviceConfig.NAMESPACE_TETHERING;
+    }
+
+    private static boolean getDeviceConfigBoolean(final String name, final boolean defaultValue) {
+        final String value = getDeviceConfigProperty(name);
+        return value != null ? Boolean.parseBoolean(value) : defaultValue;
+    }
+
+    private static int getDeviceConfigInt(final String name, final int defaultValue) {
+        final String value = getDeviceConfigProperty(name);
+        return value != null ? Integer.parseInt(value) : defaultValue;
+    }
+
+    private static String getDeviceConfigProperty(String name) {
+        return DeviceConfig.getProperty(getNamespace(), name);
     }
 
     /**
      * Returns whether broadcasting legacy presence spec is enabled.
      */
     public boolean isPresenceBroadcastLegacyEnabled() {
-        return mEnablePresenceBroadcastLegacy;
+        synchronized (mDeviceConfigLock) {
+            return mEnablePresenceBroadcastLegacy;
+        }
     }
 
-    private boolean getDeviceConfigBoolean(final String name, final boolean defaultValue) {
-        final String value = getDeviceConfigProperty(name);
-        return value != null ? Boolean.parseBoolean(value) : defaultValue;
+    public int getNanoAppMinVersion() {
+        synchronized (mDeviceConfigLock) {
+            return mNanoAppMinVersion;
+        }
     }
 
-    @VisibleForTesting
-    protected String getDeviceConfigProperty(String name) {
-        return DeviceConfig.getProperty(DeviceConfig.NAMESPACE_TETHERING, name);
+    /**
+     * @return {@code true} when in test mode and allows customization.
+     */
+    public boolean isTestAppSupported() {
+        synchronized (mDeviceConfigLock) {
+            return mSupportTestApp;
+        }
+    }
+
+    /**
+     * @return {@code true} if use {@link DiscoveryProviderManager} or use
+     * DiscoveryProviderManagerLegacy if {@code false}.
+     */
+    public boolean refactorDiscoveryManager() {
+        synchronized (mDeviceConfigLock) {
+            return mRefactorDiscoveryManager;
+        }
+    }
+
+    private class DeviceConfigListener implements DeviceConfig.OnPropertiesChangedListener {
+        public void start() {
+            DeviceConfig.addOnPropertiesChangedListener(getNamespace(),
+                    Executors.newSingleThreadExecutor(), this);
+            onPropertiesChanged(DeviceConfig.getProperties(getNamespace()));
+        }
+
+        @Override
+        public void onPropertiesChanged(@NonNull DeviceConfig.Properties properties) {
+            synchronized (mDeviceConfigLock) {
+                mEnablePresenceBroadcastLegacy = getDeviceConfigBoolean(
+                        NEARBY_ENABLE_PRESENCE_BROADCAST_LEGACY, false /* defaultValue */);
+                mNanoAppMinVersion = getDeviceConfigInt(
+                        NEARBY_MAINLINE_NANO_APP_MIN_VERSION, 0 /* defaultValue */);
+                mSupportTestApp = !IS_USER_BUILD && getDeviceConfigBoolean(
+                        NEARBY_SUPPORT_TEST_APP, false /* defaultValue */);
+                mRefactorDiscoveryManager = getDeviceConfigBoolean(
+                        NEARBY_REFACTOR_DISCOVERY_MANAGER, false /* defaultValue */);
+            }
+        }
     }
 }
diff --git a/nearby/service/java/com/android/server/nearby/NearbyService.java b/nearby/service/java/com/android/server/nearby/NearbyService.java
index 1220104..3c183ec 100644
--- a/nearby/service/java/com/android/server/nearby/NearbyService.java
+++ b/nearby/service/java/com/android/server/nearby/NearbyService.java
@@ -28,6 +28,7 @@
 import android.content.Context;
 import android.content.Intent;
 import android.content.IntentFilter;
+import android.content.pm.PackageManager;
 import android.hardware.location.ContextHubManager;
 import android.nearby.BroadcastRequestParcelable;
 import android.nearby.IBroadcastListener;
@@ -35,13 +36,16 @@
 import android.nearby.IScanListener;
 import android.nearby.NearbyManager;
 import android.nearby.ScanRequest;
+import android.nearby.aidl.IOffloadCallback;
 import android.util.Log;
 
 import com.android.internal.annotations.VisibleForTesting;
-import com.android.server.nearby.injector.ContextHubManagerAdapter;
 import com.android.server.nearby.injector.Injector;
-import com.android.server.nearby.provider.BroadcastProviderManager;
-import com.android.server.nearby.provider.DiscoveryProviderManager;
+import com.android.server.nearby.managers.BroadcastProviderManager;
+import com.android.server.nearby.managers.DiscoveryManager;
+import com.android.server.nearby.managers.DiscoveryProviderManager;
+import com.android.server.nearby.managers.DiscoveryProviderManagerLegacy;
+import com.android.server.nearby.presence.PresenceManager;
 import com.android.server.nearby.util.identity.CallerIdentity;
 import com.android.server.nearby.util.permissions.BroadcastPermissions;
 import com.android.server.nearby.util.permissions.DiscoveryPermissions;
@@ -49,8 +53,12 @@
 /** Service implementing nearby functionality. */
 public class NearbyService extends INearbyManager.Stub {
     public static final String TAG = "NearbyService";
+    // Sets to true to start BLE scan from PresenceManager for manual testing.
+    public static final Boolean MANUAL_TEST = false;
 
     private final Context mContext;
+    private final PresenceManager mPresenceManager;
+    private final NearbyConfiguration mNearbyConfiguration;
     private Injector mInjector;
     private final BroadcastReceiver mBluetoothReceiver =
             new BroadcastReceiver() {
@@ -69,14 +77,19 @@
                     }
                 }
             };
-    private DiscoveryProviderManager mProviderManager;
-    private BroadcastProviderManager mBroadcastProviderManager;
+    private final DiscoveryManager mDiscoveryProviderManager;
+    private final BroadcastProviderManager mBroadcastProviderManager;
 
     public NearbyService(Context context) {
         mContext = context;
         mInjector = new SystemInjector(context);
-        mProviderManager = new DiscoveryProviderManager(context, mInjector);
         mBroadcastProviderManager = new BroadcastProviderManager(context, mInjector);
+        mPresenceManager = new PresenceManager(context);
+        mNearbyConfiguration = new NearbyConfiguration();
+        mDiscoveryProviderManager =
+                mNearbyConfiguration.refactorDiscoveryManager()
+                        ? new DiscoveryProviderManager(context, mInjector)
+                        : new DiscoveryProviderManagerLegacy(context, mInjector);
     }
 
     @VisibleForTesting
@@ -93,10 +106,7 @@
         CallerIdentity identity = CallerIdentity.fromBinder(mContext, packageName, attributionTag);
         DiscoveryPermissions.enforceDiscoveryPermission(mContext, identity);
 
-        if (mProviderManager.registerScanListener(scanRequest, listener, identity)) {
-            return NearbyManager.ScanStatus.SUCCESS;
-        }
-        return NearbyManager.ScanStatus.ERROR;
+        return mDiscoveryProviderManager.registerScanListener(scanRequest, listener, identity);
     }
 
     @Override
@@ -107,7 +117,7 @@
         CallerIdentity identity = CallerIdentity.fromBinder(mContext, packageName, attributionTag);
         DiscoveryPermissions.enforceDiscoveryPermission(mContext, identity);
 
-        mProviderManager.unregisterScanListener(listener);
+        mDiscoveryProviderManager.unregisterScanListener(listener);
     }
 
     @Override
@@ -133,6 +143,11 @@
         mBroadcastProviderManager.stopBroadcast(listener);
     }
 
+    @Override
+    public void queryOffloadCapability(IOffloadCallback callback) {
+        mDiscoveryProviderManager.queryOffloadCapability(callback);
+    }
+
     /**
      * Called by the service initializer.
      *
@@ -146,15 +161,21 @@
                 }
                 break;
             case PHASE_BOOT_COMPLETED:
+                // mInjector needs to be initialized before mProviderManager.
                 if (mInjector instanceof SystemInjector) {
                     // The nearby service must be functioning after this boot phase.
                     ((SystemInjector) mInjector).initializeBluetoothAdapter();
                     // Initialize ContextManager for CHRE scan.
-                    ((SystemInjector) mInjector).initializeContextHubManagerAdapter();
+                    ((SystemInjector) mInjector).initializeContextHubManager();
                 }
+                mDiscoveryProviderManager.init();
                 mContext.registerReceiver(
                         mBluetoothReceiver,
                         new IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED));
+                // Only enable for manual Presence test on device.
+                if (MANUAL_TEST) {
+                    mPresenceManager.initiate();
+                }
                 break;
         }
     }
@@ -165,16 +186,18 @@
      * throw a {@link SecurityException}.
      */
     @RequiresPermission(android.Manifest.permission.BLUETOOTH_PRIVILEGED)
-    private static void enforceBluetoothPrivilegedPermission(Context context) {
-        context.enforceCallingOrSelfPermission(
-                android.Manifest.permission.BLUETOOTH_PRIVILEGED,
-                "Need BLUETOOTH PRIVILEGED permission");
+    private void enforceBluetoothPrivilegedPermission(Context context) {
+        if (!mNearbyConfiguration.isTestAppSupported()) {
+            context.enforceCallingOrSelfPermission(
+                    android.Manifest.permission.BLUETOOTH_PRIVILEGED,
+                    "Need BLUETOOTH PRIVILEGED permission");
+        }
     }
 
     private static final class SystemInjector implements Injector {
         private final Context mContext;
         @Nullable private BluetoothAdapter mBluetoothAdapter;
-        @Nullable private ContextHubManagerAdapter mContextHubManagerAdapter;
+        @Nullable private ContextHubManager mContextHubManager;
         @Nullable private AppOpsManager mAppOpsManager;
 
         SystemInjector(Context context) {
@@ -189,8 +212,8 @@
 
         @Override
         @Nullable
-        public ContextHubManagerAdapter getContextHubManagerAdapter() {
-            return mContextHubManagerAdapter;
+        public ContextHubManager getContextHubManager() {
+            return mContextHubManager;
         }
 
         @Override
@@ -210,15 +233,13 @@
             mBluetoothAdapter = manager.getAdapter();
         }
 
-        synchronized void initializeContextHubManagerAdapter() {
-            if (mContextHubManagerAdapter != null) {
+        synchronized void initializeContextHubManager() {
+            if (mContextHubManager != null) {
                 return;
             }
-            ContextHubManager manager = mContext.getSystemService(ContextHubManager.class);
-            if (manager == null) {
-                return;
+            if (mContext.getPackageManager().hasSystemFeature(PackageManager.FEATURE_CONTEXT_HUB)) {
+                mContextHubManager = mContext.getSystemService(ContextHubManager.class);
             }
-            mContextHubManagerAdapter = new ContextHubManagerAdapter(manager);
         }
 
         synchronized void initializeAppOpsManager() {
diff --git a/nearby/service/java/com/android/server/nearby/common/CancelableAlarm.java b/nearby/service/java/com/android/server/nearby/common/CancelableAlarm.java
new file mode 100644
index 0000000..00d1570
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/CancelableAlarm.java
@@ -0,0 +1,133 @@
+/*
+ * 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.nearby.common;
+
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+
+import android.util.Log;
+
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+
+/**
+ * A cancelable alarm with a name. This is a simple wrapper around the logic for posting a runnable
+ * on a scheduled executor service and (possibly) later canceling it.
+ */
+public class CancelableAlarm {
+
+    private static final String TAG = "NearbyCancelableAlarm";
+
+    private final String mName;
+    private final Runnable mRunnable;
+    private final long mDelayMillis;
+    private final ScheduledExecutorService mExecutor;
+    private final boolean mIsRecurring;
+
+    // The future containing the alarm.
+    private volatile ScheduledFuture<?> mFuture;
+
+    private CancellationFlag mCancellationFlag;
+
+    private CancelableAlarm(
+            String name,
+            Runnable runnable,
+            long delayMillis,
+            ScheduledExecutorService executor,
+            boolean isRecurring) {
+        this.mName = name;
+        this.mRunnable = runnable;
+        this.mDelayMillis = delayMillis;
+        this.mExecutor = executor;
+        this.mIsRecurring = isRecurring;
+    }
+
+    /**
+     * Creates an alarm.
+     *
+     * @param name the task name
+     * @param runnable command the task to execute
+     * @param delayMillis delay the time from now to delay execution
+     * @param executor the executor that schedules commands to run
+     */
+    public static CancelableAlarm createSingleAlarm(
+            String name,
+            Runnable runnable,
+            long delayMillis,
+            ScheduledExecutorService executor) {
+        CancelableAlarm cancelableAlarm =
+                new CancelableAlarm(name, runnable, delayMillis, executor, /* isRecurring= */
+                        false);
+        cancelableAlarm.scheduleExecutor();
+        return cancelableAlarm;
+    }
+
+    /**
+     * Creates a recurring alarm.
+     *
+     * @param name the task name
+     * @param runnable command the task to execute
+     * @param delayMillis delay the time from now to delay execution
+     * @param executor the executor that schedules commands to run
+     */
+    public static CancelableAlarm createRecurringAlarm(
+            String name,
+            Runnable runnable,
+            long delayMillis,
+            ScheduledExecutorService executor) {
+        CancelableAlarm cancelableAlarm =
+                new CancelableAlarm(name, runnable, delayMillis, executor, /* isRecurring= */ true);
+        cancelableAlarm.scheduleExecutor();
+        return cancelableAlarm;
+    }
+
+    // A reference to "this" should generally not be passed to another class within the constructor
+    // as it may not have completed being constructed.
+    private void scheduleExecutor() {
+        this.mFuture = mExecutor.schedule(this::processAlarm, mDelayMillis, MILLISECONDS);
+        // For tests to pass (NearbySharingChimeraServiceTest) the Cancellation Flag must come
+       // after the
+        // executor.  Doing so prevents the test code from running the callback immediately.
+        this.mCancellationFlag = new CancellationFlag();
+    }
+
+    /**
+     * Cancels the pending alarm.
+     *
+     * @return true if the alarm was canceled, or false if there was a problem canceling the alarm.
+     */
+    public boolean cancel() {
+        mCancellationFlag.cancel();
+        try {
+            return mFuture.cancel(/* mayInterruptIfRunning= */ true);
+        } finally {
+            Log.v(TAG, "Canceled " + mName + " alarm");
+        }
+    }
+
+    private void processAlarm() {
+        if (mCancellationFlag.isCancelled()) {
+            Log.v(TAG, "Ignoring " + mName + " alarm because it has previously been canceled");
+            return;
+        }
+
+        Log.v(TAG, "Running " + mName + " alarm");
+        mRunnable.run();
+        if (mIsRecurring) {
+            this.mFuture = mExecutor.schedule(this::processAlarm, mDelayMillis, MILLISECONDS);
+        }
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/CancellationFlag.java b/nearby/service/java/com/android/server/nearby/common/CancellationFlag.java
new file mode 100644
index 0000000..f0bb075
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/CancellationFlag.java
@@ -0,0 +1,89 @@
+/*
+ * 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.nearby.common;
+
+import android.util.ArraySet;
+
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * A cancellation flag to mark an operation has been cancelled and should be cleaned up as soon as
+ * possible.
+ */
+public class CancellationFlag {
+
+    private final Set<OnCancelListener> mListeners = new ArraySet<>();
+    private final AtomicBoolean mIsCancelled = new AtomicBoolean();
+
+    public CancellationFlag() {
+        this(false);
+    }
+
+    public CancellationFlag(boolean isCancelled) {
+        this.mIsCancelled.set(isCancelled);
+    }
+
+    /** Set the flag as cancelled. */
+    public void cancel() {
+        if (mIsCancelled.getAndSet(true)) {
+            // Someone already cancelled. Return immediately.
+            return;
+        }
+
+        // Don't invoke OnCancelListener#onCancel inside the synchronization block, as it makes
+        // deadlocks more likely.
+        Set<OnCancelListener> clonedListeners;
+        synchronized (this) {
+            clonedListeners = new ArraySet<>(mListeners);
+        }
+        for (OnCancelListener listener : clonedListeners) {
+            listener.onCancel();
+        }
+    }
+
+    /** Returns {@code true} if the flag has been set to cancelled. */
+    public synchronized boolean isCancelled() {
+        return mIsCancelled.get();
+    }
+
+    /** Returns the flag as an {@link AtomicBoolean} object. */
+    public synchronized AtomicBoolean asAtomicBoolean() {
+        return mIsCancelled;
+    }
+
+    /** Registers a {@link OnCancelListener} to listen to cancel() event. */
+    public synchronized void registerOnCancelListener(OnCancelListener listener) {
+        mListeners.add(listener);
+    }
+
+    /**
+     * Unregisters a {@link OnCancelListener} that was previously registed through {@link
+     * #registerOnCancelListener(OnCancelListener)}.
+     */
+    public synchronized void unregisterOnCancelListener(OnCancelListener listener) {
+        mListeners.remove(listener);
+    }
+
+    /** Listens to {@link CancellationFlag#cancel()}. */
+    public interface OnCancelListener {
+        /**
+         * When CancellationFlag is canceled.
+         */
+        void onCancel();
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/injector/Injector.java b/nearby/service/java/com/android/server/nearby/injector/Injector.java
index 57784a9..3152ee6 100644
--- a/nearby/service/java/com/android/server/nearby/injector/Injector.java
+++ b/nearby/service/java/com/android/server/nearby/injector/Injector.java
@@ -18,6 +18,7 @@
 
 import android.app.AppOpsManager;
 import android.bluetooth.BluetoothAdapter;
+import android.hardware.location.ContextHubManager;
 
 /**
  * Nearby dependency injector. To be used for accessing various Nearby class instances and as a
@@ -29,7 +30,7 @@
     BluetoothAdapter getBluetoothAdapter();
 
     /** Get the ContextHubManagerAdapter for ChreDiscoveryProvider to scan. */
-    ContextHubManagerAdapter getContextHubManagerAdapter();
+    ContextHubManager getContextHubManager();
 
     /** Get the AppOpsManager to control access. */
     AppOpsManager getAppOpsManager();
diff --git a/nearby/service/java/com/android/server/nearby/provider/BroadcastProviderManager.java b/nearby/service/java/com/android/server/nearby/managers/BroadcastProviderManager.java
similarity index 70%
rename from nearby/service/java/com/android/server/nearby/provider/BroadcastProviderManager.java
rename to nearby/service/java/com/android/server/nearby/managers/BroadcastProviderManager.java
index 3fffda5..024bff8 100644
--- a/nearby/service/java/com/android/server/nearby/provider/BroadcastProviderManager.java
+++ b/nearby/service/java/com/android/server/nearby/managers/BroadcastProviderManager.java
@@ -1,5 +1,5 @@
 /*
- * Copyright (C) 2022 The Android Open Source Project
+ * 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.
@@ -14,8 +14,9 @@
  * limitations under the License.
  */
 
-package com.android.server.nearby.provider;
+package com.android.server.nearby.managers;
 
+import android.annotation.Nullable;
 import android.content.Context;
 import android.nearby.BroadcastCallback;
 import android.nearby.BroadcastRequest;
@@ -27,7 +28,10 @@
 import com.android.internal.annotations.VisibleForTesting;
 import com.android.server.nearby.NearbyConfiguration;
 import com.android.server.nearby.injector.Injector;
+import com.android.server.nearby.presence.Advertisement;
+import com.android.server.nearby.presence.ExtendedAdvertisement;
 import com.android.server.nearby.presence.FastAdvertisement;
+import com.android.server.nearby.provider.BleBroadcastProvider;
 import com.android.server.nearby.util.ForegroundThread;
 
 import java.util.concurrent.Executor;
@@ -66,10 +70,12 @@
     public void startBroadcast(BroadcastRequest broadcastRequest, IBroadcastListener listener) {
         synchronized (mLock) {
             mExecutor.execute(() -> {
-                NearbyConfiguration configuration = new NearbyConfiguration();
-                if (!configuration.isPresenceBroadcastLegacyEnabled()) {
-                    reportBroadcastStatus(listener, BroadcastCallback.STATUS_FAILURE);
-                    return;
+                if (!mNearbyConfiguration.isTestAppSupported()) {
+                    NearbyConfiguration configuration = new NearbyConfiguration();
+                    if (!configuration.isPresenceBroadcastLegacyEnabled()) {
+                        reportBroadcastStatus(listener, BroadcastCallback.STATUS_FAILURE);
+                        return;
+                    }
                 }
                 if (broadcastRequest.getType() != BroadcastRequest.BROADCAST_TYPE_NEARBY_PRESENCE) {
                     reportBroadcastStatus(listener, BroadcastCallback.STATUS_FAILURE);
@@ -77,25 +83,38 @@
                 }
                 PresenceBroadcastRequest presenceBroadcastRequest =
                         (PresenceBroadcastRequest) broadcastRequest;
-                if (presenceBroadcastRequest.getVersion() != BroadcastRequest.PRESENCE_VERSION_V0) {
+                Advertisement advertisement = getAdvertisement(presenceBroadcastRequest);
+                if (advertisement == null) {
+                    Log.e(TAG, "Failed to start broadcast because broadcastRequest is illegal.");
                     reportBroadcastStatus(listener, BroadcastCallback.STATUS_FAILURE);
                     return;
                 }
-                FastAdvertisement fastAdvertisement = FastAdvertisement.createFromRequest(
-                        presenceBroadcastRequest);
-                byte[] advertisementPackets = fastAdvertisement.toBytes();
                 mBroadcastListener = listener;
-                mBleBroadcastProvider.start(advertisementPackets, this);
+                mBleBroadcastProvider.start(presenceBroadcastRequest.getVersion(),
+                        advertisement.toBytes(), this);
             });
         }
     }
 
+    @Nullable
+    private Advertisement getAdvertisement(PresenceBroadcastRequest request) {
+        switch (request.getVersion()) {
+            case BroadcastRequest.PRESENCE_VERSION_V0:
+                return FastAdvertisement.createFromRequest(request);
+            case BroadcastRequest.PRESENCE_VERSION_V1:
+                return ExtendedAdvertisement.createFromRequest(request);
+            default:
+                return null;
+        }
+    }
+
     /**
      * Stops the nearby broadcast.
      */
     public void stopBroadcast(IBroadcastListener listener) {
         synchronized (mLock) {
-            if (!mNearbyConfiguration.isPresenceBroadcastLegacyEnabled()) {
+            if (!mNearbyConfiguration.isTestAppSupported()
+                    && !mNearbyConfiguration.isPresenceBroadcastLegacyEnabled()) {
                 reportBroadcastStatus(listener, BroadcastCallback.STATUS_FAILURE);
                 return;
             }
diff --git a/nearby/service/java/com/android/server/nearby/managers/DiscoveryManager.java b/nearby/service/java/com/android/server/nearby/managers/DiscoveryManager.java
new file mode 100644
index 0000000..c9b9a43
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/managers/DiscoveryManager.java
@@ -0,0 +1,49 @@
+/*
+ * 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.nearby.managers;
+
+import android.nearby.IScanListener;
+import android.nearby.NearbyManager;
+import android.nearby.ScanRequest;
+import android.nearby.aidl.IOffloadCallback;
+
+import com.android.server.nearby.util.identity.CallerIdentity;
+
+/**
+ * Interface added for flagging DiscoveryProviderManager refactor. After the
+ * nearby_refactor_discovery_manager flag is fully rolled out, this can be deleted.
+ */
+public interface DiscoveryManager {
+
+    /**
+     * Registers the listener in the manager and starts scan according to the requested scan mode.
+     */
+    @NearbyManager.ScanStatus
+    int registerScanListener(ScanRequest scanRequest, IScanListener listener,
+            CallerIdentity callerIdentity);
+
+    /**
+     * Unregisters the listener in the manager and adjusts the scan mode if necessary afterwards.
+     */
+    void unregisterScanListener(IScanListener listener);
+
+    /** Query offload capability in a device. */
+    void queryOffloadCapability(IOffloadCallback callback);
+
+    /** Called after boot completed. */
+    void init();
+}
diff --git a/nearby/service/java/com/android/server/nearby/managers/DiscoveryProviderManager.java b/nearby/service/java/com/android/server/nearby/managers/DiscoveryProviderManager.java
new file mode 100644
index 0000000..0c41426
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/managers/DiscoveryProviderManager.java
@@ -0,0 +1,316 @@
+/*
+ * 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.nearby.managers;
+
+import static android.nearby.ScanRequest.SCAN_TYPE_NEARBY_PRESENCE;
+
+import static com.android.server.nearby.NearbyService.TAG;
+
+import android.annotation.Nullable;
+import android.content.Context;
+import android.nearby.DataElement;
+import android.nearby.IScanListener;
+import android.nearby.NearbyDeviceParcelable;
+import android.nearby.NearbyManager;
+import android.nearby.PresenceScanFilter;
+import android.nearby.ScanFilter;
+import android.nearby.ScanRequest;
+import android.nearby.aidl.IOffloadCallback;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.server.nearby.injector.Injector;
+import com.android.server.nearby.managers.registration.DiscoveryRegistration;
+import com.android.server.nearby.provider.AbstractDiscoveryProvider;
+import com.android.server.nearby.provider.BleDiscoveryProvider;
+import com.android.server.nearby.provider.ChreCommunication;
+import com.android.server.nearby.provider.ChreDiscoveryProvider;
+import com.android.server.nearby.util.identity.CallerIdentity;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+
+import javax.annotation.concurrent.GuardedBy;
+
+/** Manages all aspects of discovery providers. */
+public class DiscoveryProviderManager extends
+        ListenerMultiplexer<IScanListener, DiscoveryRegistration, MergedDiscoveryRequest> implements
+        AbstractDiscoveryProvider.Listener,
+        DiscoveryManager {
+
+    protected final Object mLock = new Object();
+    @VisibleForTesting
+    @Nullable
+    final ChreDiscoveryProvider mChreDiscoveryProvider;
+    private final Context mContext;
+    private final BleDiscoveryProvider mBleDiscoveryProvider;
+    private final Injector mInjector;
+    private final Executor mExecutor;
+
+    public DiscoveryProviderManager(Context context, Injector injector) {
+        Log.v(TAG, "DiscoveryProviderManager: ");
+        mContext = context;
+        mBleDiscoveryProvider = new BleDiscoveryProvider(mContext, injector);
+        mExecutor = Executors.newSingleThreadExecutor();
+        mChreDiscoveryProvider = new ChreDiscoveryProvider(mContext,
+                new ChreCommunication(injector, mContext, mExecutor), mExecutor);
+        mInjector = injector;
+    }
+
+    @VisibleForTesting
+    DiscoveryProviderManager(Context context, Executor executor, Injector injector,
+            BleDiscoveryProvider bleDiscoveryProvider,
+            ChreDiscoveryProvider chreDiscoveryProvider) {
+        mContext = context;
+        mExecutor = executor;
+        mInjector = injector;
+        mBleDiscoveryProvider = bleDiscoveryProvider;
+        mChreDiscoveryProvider = chreDiscoveryProvider;
+    }
+
+    private static boolean isChreOnly(Set<ScanFilter> scanFilters) {
+        for (ScanFilter scanFilter : scanFilters) {
+            List<DataElement> dataElements =
+                    ((PresenceScanFilter) scanFilter).getExtendedProperties();
+            for (DataElement dataElement : dataElements) {
+                if (dataElement.getKey() != DataElement.DataType.SCAN_MODE) {
+                    continue;
+                }
+                byte[] scanModeValue = dataElement.getValue();
+                if (scanModeValue == null || scanModeValue.length == 0) {
+                    break;
+                }
+                if (Byte.toUnsignedInt(scanModeValue[0]) == ScanRequest.SCAN_MODE_CHRE_ONLY) {
+                    return true;
+                }
+            }
+
+        }
+        return false;
+    }
+
+    @Override
+    public void onNearbyDeviceDiscovered(NearbyDeviceParcelable nearbyDevice) {
+        synchronized (mMultiplexerLock) {
+            Log.d(TAG, "Found device" + nearbyDevice);
+            deliverToListeners(registration -> {
+                try {
+                    return registration.onNearbyDeviceDiscovered(nearbyDevice);
+                } catch (Exception e) {
+                    Log.w(TAG, "DiscoveryProviderManager failed to report callback.", e);
+                    return null;
+                }
+            });
+        }
+    }
+
+    @Override
+    public void onError(int errorCode) {
+        synchronized (mMultiplexerLock) {
+            Log.e(TAG, "Error found during scanning.");
+            deliverToListeners(registration -> {
+                try {
+                    return registration.reportError(errorCode);
+                } catch (Exception e) {
+                    Log.w(TAG, "DiscoveryProviderManager failed to report error.", e);
+                    return null;
+                }
+            });
+        }
+    }
+
+    /** Called after boot completed. */
+    public void init() {
+        if (mInjector.getContextHubManager() != null) {
+            mChreDiscoveryProvider.init();
+        }
+        mChreDiscoveryProvider.getController().setListener(this);
+    }
+
+    /**
+     * Registers the listener in the manager and starts scan according to the requested scan mode.
+     */
+    @NearbyManager.ScanStatus
+    public int registerScanListener(ScanRequest scanRequest, IScanListener listener,
+            CallerIdentity callerIdentity) {
+        DiscoveryRegistration registration = new DiscoveryRegistration(this, scanRequest, listener,
+                mExecutor, callerIdentity, mMultiplexerLock, mInjector.getAppOpsManager());
+        synchronized (mMultiplexerLock) {
+            putRegistration(listener.asBinder(), registration);
+            return NearbyManager.ScanStatus.SUCCESS;
+        }
+    }
+
+    @Override
+    public void onRegister() {
+        Log.v(TAG, "Registering the DiscoveryProviderManager.");
+        startProviders();
+    }
+
+    @Override
+    public void onUnregister() {
+        Log.v(TAG, "Unregistering the DiscoveryProviderManager.");
+        stopProviders();
+    }
+
+    /**
+     * Unregisters the listener in the manager and adjusts the scan mode if necessary afterwards.
+     */
+    public void unregisterScanListener(IScanListener listener) {
+        Log.v(TAG, "Unregister scan listener");
+        synchronized (mMultiplexerLock) {
+            removeRegistration(listener.asBinder());
+        }
+        // TODO(b/221082271): updates the scan with reduced filters.
+    }
+
+    /**
+     * Query offload capability in a device.
+     */
+    public void queryOffloadCapability(IOffloadCallback callback) {
+        mChreDiscoveryProvider.queryOffloadCapability(callback);
+    }
+
+    /**
+     * @return {@code null} when all providers are initializing
+     * {@code false} when fail to start all the providers
+     * {@code true} when any one of the provider starts successfully
+     */
+    @VisibleForTesting
+    @Nullable
+    Boolean startProviders() {
+        synchronized (mMultiplexerLock) {
+            if (!mMerged.getMediums().contains(MergedDiscoveryRequest.Medium.BLE)) {
+                Log.w(TAG, "failed to start any provider because client disabled BLE");
+                return false;
+            }
+            Set<ScanFilter> scanFilters = mMerged.getScanFilters();
+            boolean chreOnly = isChreOnly(scanFilters);
+            Boolean chreAvailable = mChreDiscoveryProvider.available();
+            Log.v(TAG, "startProviders: chreOnly " + chreOnly + " chreAvailable " + chreAvailable);
+            if (chreAvailable == null) {
+                if (chreOnly) {
+                    Log.w(TAG, "client wants CHRE only and Nearby service is still querying CHRE"
+                            + " status");
+                    return null;
+                }
+                startBleProvider(scanFilters);
+                return true;
+            }
+
+            if (!chreAvailable) {
+                if (chreOnly) {
+                    Log.w(TAG,
+                            "failed to start any provider because client wants CHRE only and CHRE"
+                                    + " is not available");
+                    return false;
+                }
+                startBleProvider(scanFilters);
+                return true;
+            }
+
+            if (mMerged.getScanTypes().contains(SCAN_TYPE_NEARBY_PRESENCE)) {
+                startChreProvider(scanFilters);
+                return true;
+            }
+
+            startBleProvider(scanFilters);
+            return true;
+        }
+    }
+
+    @GuardedBy("mMultiplexerLock")
+    private void startBleProvider(Set<ScanFilter> scanFilters) {
+        if (!mBleDiscoveryProvider.getController().isStarted()) {
+            Log.d(TAG, "DiscoveryProviderManager starts Ble scanning.");
+            mBleDiscoveryProvider.getController().setListener(this);
+            mBleDiscoveryProvider.getController().setProviderScanMode(mMerged.getScanMode());
+            mBleDiscoveryProvider.getController().setProviderScanFilters(
+                    new ArrayList<>(scanFilters));
+            mBleDiscoveryProvider.getController().start();
+        }
+    }
+
+    @VisibleForTesting
+    @GuardedBy("mMultiplexerLock")
+    void startChreProvider(Collection<ScanFilter> scanFilters) {
+        Log.d(TAG, "DiscoveryProviderManager starts CHRE scanning. " + mMerged);
+        mChreDiscoveryProvider.getController().setProviderScanFilters(new ArrayList<>(scanFilters));
+        mChreDiscoveryProvider.getController().setProviderScanMode(mMerged.getScanMode());
+        mChreDiscoveryProvider.getController().start();
+    }
+
+    private void stopProviders() {
+        stopBleProvider();
+        stopChreProvider();
+    }
+
+    private void stopBleProvider() {
+        mBleDiscoveryProvider.getController().stop();
+    }
+
+    @VisibleForTesting
+    protected void stopChreProvider() {
+        mChreDiscoveryProvider.getController().stop();
+    }
+
+    @VisibleForTesting
+    void invalidateProviderScanMode() {
+        if (mBleDiscoveryProvider.getController().isStarted()) {
+            synchronized (mMultiplexerLock) {
+                mBleDiscoveryProvider.getController().setProviderScanMode(mMerged.getScanMode());
+            }
+        } else {
+            Log.d(TAG, "Skip invalidating BleDiscoveryProvider scan mode because the provider not "
+                    + "started.");
+        }
+    }
+
+    @Override
+    public MergedDiscoveryRequest mergeRegistrations(
+            @NonNull Collection<DiscoveryRegistration> registrations) {
+        MergedDiscoveryRequest.Builder builder = new MergedDiscoveryRequest.Builder();
+        int scanMode = ScanRequest.SCAN_MODE_NO_POWER;
+        for (DiscoveryRegistration registration : registrations) {
+            builder.addActions(registration.getActions());
+            builder.addScanFilters(registration.getPresenceScanFilters());
+            Log.d(TAG,
+                    "mergeRegistrations: type is " + registration.getScanRequest().getScanType());
+            builder.addScanType(registration.getScanRequest().getScanType());
+            if (registration.getScanRequest().isBleEnabled()) {
+                builder.addMedium(MergedDiscoveryRequest.Medium.BLE);
+            }
+            int requestScanMode = registration.getScanRequest().getScanMode();
+            if (scanMode < requestScanMode) {
+                scanMode = requestScanMode;
+            }
+        }
+        builder.setScanMode(scanMode);
+        return builder.build();
+    }
+
+    @Override
+    public void onMergedRegistrationsUpdated() {
+        invalidateProviderScanMode();
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/managers/DiscoveryProviderManagerLegacy.java b/nearby/service/java/com/android/server/nearby/managers/DiscoveryProviderManagerLegacy.java
new file mode 100644
index 0000000..4b76eba
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/managers/DiscoveryProviderManagerLegacy.java
@@ -0,0 +1,506 @@
+/*
+ * 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.nearby.managers;
+
+import static android.nearby.ScanRequest.SCAN_TYPE_NEARBY_PRESENCE;
+
+import static com.android.server.nearby.NearbyService.TAG;
+
+import android.annotation.Nullable;
+import android.app.AppOpsManager;
+import android.content.Context;
+import android.nearby.DataElement;
+import android.nearby.IScanListener;
+import android.nearby.NearbyDeviceParcelable;
+import android.nearby.NearbyManager;
+import android.nearby.PresenceScanFilter;
+import android.nearby.ScanCallback;
+import android.nearby.ScanFilter;
+import android.nearby.ScanRequest;
+import android.nearby.aidl.IOffloadCallback;
+import android.os.IBinder;
+import android.os.RemoteException;
+import android.util.Log;
+
+import com.android.internal.annotations.GuardedBy;
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.server.nearby.injector.Injector;
+import com.android.server.nearby.metrics.NearbyMetrics;
+import com.android.server.nearby.presence.PresenceDiscoveryResult;
+import com.android.server.nearby.provider.AbstractDiscoveryProvider;
+import com.android.server.nearby.provider.BleDiscoveryProvider;
+import com.android.server.nearby.provider.ChreCommunication;
+import com.android.server.nearby.provider.ChreDiscoveryProvider;
+import com.android.server.nearby.provider.PrivacyFilter;
+import com.android.server.nearby.util.identity.CallerIdentity;
+import com.android.server.nearby.util.permissions.DiscoveryPermissions;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+import java.util.stream.Collectors;
+
+/** Manages all aspects of discovery providers. */
+public class DiscoveryProviderManagerLegacy implements AbstractDiscoveryProvider.Listener,
+        DiscoveryManager {
+
+    protected final Object mLock = new Object();
+    @VisibleForTesting
+    @Nullable
+    final ChreDiscoveryProvider mChreDiscoveryProvider;
+    private final Context mContext;
+    private final BleDiscoveryProvider mBleDiscoveryProvider;
+    private final Injector mInjector;
+    @ScanRequest.ScanMode
+    private int mScanMode;
+    @GuardedBy("mLock")
+    private final Map<IBinder, ScanListenerRecord> mScanTypeScanListenerRecordMap;
+
+    public DiscoveryProviderManagerLegacy(Context context, Injector injector) {
+        mContext = context;
+        mBleDiscoveryProvider = new BleDiscoveryProvider(mContext, injector);
+        Executor executor = Executors.newSingleThreadExecutor();
+        mChreDiscoveryProvider =
+                new ChreDiscoveryProvider(
+                        mContext, new ChreCommunication(injector, mContext, executor), executor);
+        mScanTypeScanListenerRecordMap = new HashMap<>();
+        mInjector = injector;
+        Log.v(TAG, "DiscoveryProviderManagerLegacy: ");
+    }
+
+    @VisibleForTesting
+    DiscoveryProviderManagerLegacy(Context context, Injector injector,
+            BleDiscoveryProvider bleDiscoveryProvider,
+            ChreDiscoveryProvider chreDiscoveryProvider,
+            Map<IBinder, ScanListenerRecord> scanTypeScanListenerRecordMap) {
+        mContext = context;
+        mInjector = injector;
+        mBleDiscoveryProvider = bleDiscoveryProvider;
+        mChreDiscoveryProvider = chreDiscoveryProvider;
+        mScanTypeScanListenerRecordMap = scanTypeScanListenerRecordMap;
+    }
+
+    private static boolean isChreOnly(List<ScanFilter> scanFilters) {
+        for (ScanFilter scanFilter : scanFilters) {
+            List<DataElement> dataElements =
+                    ((PresenceScanFilter) scanFilter).getExtendedProperties();
+            for (DataElement dataElement : dataElements) {
+                if (dataElement.getKey() != DataElement.DataType.SCAN_MODE) {
+                    continue;
+                }
+                byte[] scanModeValue = dataElement.getValue();
+                if (scanModeValue == null || scanModeValue.length == 0) {
+                    break;
+                }
+                if (Byte.toUnsignedInt(scanModeValue[0]) == ScanRequest.SCAN_MODE_CHRE_ONLY) {
+                    return true;
+                }
+            }
+
+        }
+        return false;
+    }
+
+    @VisibleForTesting
+    static boolean presenceFilterMatches(
+            NearbyDeviceParcelable device, List<ScanFilter> scanFilters) {
+        if (scanFilters.isEmpty()) {
+            return true;
+        }
+        PresenceDiscoveryResult discoveryResult = PresenceDiscoveryResult.fromDevice(device);
+        for (ScanFilter scanFilter : scanFilters) {
+            PresenceScanFilter presenceScanFilter = (PresenceScanFilter) scanFilter;
+            if (discoveryResult.matches(presenceScanFilter)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    @Override
+    public void onNearbyDeviceDiscovered(NearbyDeviceParcelable nearbyDevice) {
+        synchronized (mLock) {
+            AppOpsManager appOpsManager = Objects.requireNonNull(mInjector.getAppOpsManager());
+            for (IBinder listenerBinder : mScanTypeScanListenerRecordMap.keySet()) {
+                ScanListenerRecord record = mScanTypeScanListenerRecordMap.get(listenerBinder);
+                if (record == null) {
+                    Log.w(TAG, "DiscoveryProviderManager cannot find the scan record.");
+                    continue;
+                }
+                CallerIdentity callerIdentity = record.getCallerIdentity();
+                if (!DiscoveryPermissions.noteDiscoveryResultDelivery(
+                        appOpsManager, callerIdentity)) {
+                    Log.w(TAG, "[DiscoveryProviderManager] scan permission revoked "
+                            + "- not forwarding results");
+                    try {
+                        record.getScanListener().onError(ScanCallback.ERROR_PERMISSION_DENIED);
+                    } catch (RemoteException e) {
+                        Log.w(TAG, "DiscoveryProviderManager failed to report error.", e);
+                    }
+                    return;
+                }
+
+                if (nearbyDevice.getScanType() == SCAN_TYPE_NEARBY_PRESENCE) {
+                    List<ScanFilter> presenceFilters =
+                            record.getScanRequest().getScanFilters().stream()
+                                    .filter(
+                                            scanFilter ->
+                                                    scanFilter.getType()
+                                                            == SCAN_TYPE_NEARBY_PRESENCE)
+                                    .collect(Collectors.toList());
+                    if (!presenceFilterMatches(nearbyDevice, presenceFilters)) {
+                        Log.d(TAG, "presence filter does not match for "
+                                + "the scanned Presence Device");
+                        continue;
+                    }
+                }
+                try {
+                    record.getScanListener()
+                            .onDiscovered(
+                                    PrivacyFilter.filter(
+                                            record.getScanRequest().getScanType(), nearbyDevice));
+                    NearbyMetrics.logScanDeviceDiscovered(
+                            record.hashCode(), record.getScanRequest(), nearbyDevice);
+                } catch (RemoteException e) {
+                    Log.w(TAG, "DiscoveryProviderManager failed to report onDiscovered.", e);
+                }
+            }
+        }
+    }
+
+    @Override
+    public void onError(int errorCode) {
+        synchronized (mLock) {
+            AppOpsManager appOpsManager = Objects.requireNonNull(mInjector.getAppOpsManager());
+            for (IBinder listenerBinder : mScanTypeScanListenerRecordMap.keySet()) {
+                ScanListenerRecord record = mScanTypeScanListenerRecordMap.get(listenerBinder);
+                if (record == null) {
+                    Log.w(TAG, "DiscoveryProviderManager cannot find the scan record.");
+                    continue;
+                }
+                CallerIdentity callerIdentity = record.getCallerIdentity();
+                if (!DiscoveryPermissions.noteDiscoveryResultDelivery(
+                        appOpsManager, callerIdentity)) {
+                    Log.w(TAG, "[DiscoveryProviderManager] scan permission revoked "
+                            + "- not forwarding results");
+                    try {
+                        record.getScanListener().onError(ScanCallback.ERROR_PERMISSION_DENIED);
+                    } catch (RemoteException e) {
+                        Log.w(TAG, "DiscoveryProviderManager failed to report error.", e);
+                    }
+                    return;
+                }
+
+                try {
+                    record.getScanListener().onError(errorCode);
+                } catch (RemoteException e) {
+                    Log.w(TAG, "DiscoveryProviderManager failed to report onError.", e);
+                }
+            }
+        }
+    }
+
+    /** Called after boot completed. */
+    public void init() {
+        if (mInjector.getContextHubManager() != null) {
+            mChreDiscoveryProvider.init();
+        }
+        mChreDiscoveryProvider.getController().setListener(this);
+    }
+
+    /**
+     * Registers the listener in the manager and starts scan according to the requested scan mode.
+     */
+    @NearbyManager.ScanStatus
+    public int registerScanListener(ScanRequest scanRequest, IScanListener listener,
+            CallerIdentity callerIdentity) {
+        synchronized (mLock) {
+            ScanListenerDeathRecipient deathRecipient = (listener != null)
+                    ? new ScanListenerDeathRecipient(listener) : null;
+            IBinder listenerBinder = listener.asBinder();
+            if (listenerBinder != null && deathRecipient != null) {
+                try {
+                    listenerBinder.linkToDeath(deathRecipient, 0);
+                } catch (RemoteException e) {
+                    throw new IllegalArgumentException("Can't link to scan listener's death");
+                }
+            }
+            if (mScanTypeScanListenerRecordMap.containsKey(listener.asBinder())) {
+                ScanRequest savedScanRequest =
+                        mScanTypeScanListenerRecordMap.get(listenerBinder).getScanRequest();
+                if (scanRequest.equals(savedScanRequest)) {
+                    Log.d(TAG, "Already registered the scanRequest: " + scanRequest);
+                    return NearbyManager.ScanStatus.SUCCESS;
+                }
+            }
+            ScanListenerRecord scanListenerRecord =
+                    new ScanListenerRecord(scanRequest, listener, callerIdentity, deathRecipient);
+
+            mScanTypeScanListenerRecordMap.put(listenerBinder, scanListenerRecord);
+            Boolean started = startProviders(scanRequest);
+            if (started == null) {
+                mScanTypeScanListenerRecordMap.remove(listenerBinder);
+                return NearbyManager.ScanStatus.UNKNOWN;
+            }
+            if (!started) {
+                mScanTypeScanListenerRecordMap.remove(listenerBinder);
+                return NearbyManager.ScanStatus.ERROR;
+            }
+            NearbyMetrics.logScanStarted(scanListenerRecord.hashCode(), scanRequest);
+            if (mScanMode < scanRequest.getScanMode()) {
+                mScanMode = scanRequest.getScanMode();
+                invalidateProviderScanMode();
+            }
+            return NearbyManager.ScanStatus.SUCCESS;
+        }
+    }
+
+    /**
+     * Unregisters the listener in the manager and adjusts the scan mode if necessary afterwards.
+     */
+    public void unregisterScanListener(IScanListener listener) {
+        IBinder listenerBinder = listener.asBinder();
+        synchronized (mLock) {
+            if (!mScanTypeScanListenerRecordMap.containsKey(listenerBinder)) {
+                Log.w(
+                        TAG,
+                        "Cannot unregister the scanRequest because the request is never "
+                                + "registered.");
+                return;
+            }
+
+            ScanListenerRecord removedRecord =
+                    mScanTypeScanListenerRecordMap.remove(listenerBinder);
+            ScanListenerDeathRecipient deathRecipient = removedRecord.getDeathRecipient();
+            if (listenerBinder != null && deathRecipient != null) {
+                listenerBinder.unlinkToDeath(removedRecord.getDeathRecipient(), 0);
+            }
+            Log.v(TAG, "DiscoveryProviderManager unregistered scan listener.");
+            NearbyMetrics.logScanStopped(removedRecord.hashCode(), removedRecord.getScanRequest());
+            if (mScanTypeScanListenerRecordMap.isEmpty()) {
+                Log.v(TAG, "DiscoveryProviderManager stops provider because there is no "
+                        + "scan listener registered.");
+                stopProviders();
+                return;
+            }
+
+            // TODO(b/221082271): updates the scan with reduced filters.
+
+            // Removes current highest scan mode requested and sets the next highest scan mode.
+            if (removedRecord.getScanRequest().getScanMode() == mScanMode) {
+                Log.v(TAG, "DiscoveryProviderManager starts to find the new highest scan mode "
+                        + "because the highest scan mode listener was unregistered.");
+                @ScanRequest.ScanMode int highestScanModeRequested = ScanRequest.SCAN_MODE_NO_POWER;
+                // find the next highest scan mode;
+                for (ScanListenerRecord record : mScanTypeScanListenerRecordMap.values()) {
+                    @ScanRequest.ScanMode int scanMode = record.getScanRequest().getScanMode();
+                    if (scanMode > highestScanModeRequested) {
+                        highestScanModeRequested = scanMode;
+                    }
+                }
+                if (mScanMode != highestScanModeRequested) {
+                    mScanMode = highestScanModeRequested;
+                    invalidateProviderScanMode();
+                }
+            }
+        }
+    }
+
+    /**
+     * Query offload capability in a device.
+     */
+    public void queryOffloadCapability(IOffloadCallback callback) {
+        mChreDiscoveryProvider.queryOffloadCapability(callback);
+    }
+
+    /**
+     * @return {@code null} when all providers are initializing
+     * {@code false} when fail to start all the providers
+     * {@code true} when any one of the provider starts successfully
+     */
+    @VisibleForTesting
+    @Nullable
+    Boolean startProviders(ScanRequest scanRequest) {
+        if (!scanRequest.isBleEnabled()) {
+            Log.w(TAG, "failed to start any provider because client disabled BLE");
+            return false;
+        }
+        List<ScanFilter> scanFilters = getPresenceScanFilters();
+        boolean chreOnly = isChreOnly(scanFilters);
+        Boolean chreAvailable = mChreDiscoveryProvider.available();
+        if (chreAvailable == null) {
+            if (chreOnly) {
+                Log.w(TAG, "client wants CHRE only and Nearby service is still querying CHRE"
+                        + " status");
+                return null;
+            }
+            startBleProvider(scanFilters);
+            return true;
+        }
+
+        if (!chreAvailable) {
+            if (chreOnly) {
+                Log.w(TAG, "failed to start any provider because client wants CHRE only and CHRE"
+                        + " is not available");
+                return false;
+            }
+            startBleProvider(scanFilters);
+            return true;
+        }
+
+        if (scanRequest.getScanType() == SCAN_TYPE_NEARBY_PRESENCE) {
+            startChreProvider(scanFilters);
+            return true;
+        }
+
+        startBleProvider(scanFilters);
+        return true;
+    }
+
+    private void startBleProvider(List<ScanFilter> scanFilters) {
+        if (!mBleDiscoveryProvider.getController().isStarted()) {
+            Log.d(TAG, "DiscoveryProviderManager starts Ble scanning.");
+            mBleDiscoveryProvider.getController().setListener(this);
+            mBleDiscoveryProvider.getController().setProviderScanMode(mScanMode);
+            mBleDiscoveryProvider.getController().setProviderScanFilters(scanFilters);
+            mBleDiscoveryProvider.getController().start();
+        }
+    }
+
+    @VisibleForTesting
+    void startChreProvider(List<ScanFilter> scanFilters) {
+        Log.d(TAG, "DiscoveryProviderManager starts CHRE scanning.");
+        mChreDiscoveryProvider.getController().setProviderScanFilters(scanFilters);
+        mChreDiscoveryProvider.getController().setProviderScanMode(mScanMode);
+        mChreDiscoveryProvider.getController().start();
+    }
+
+    private List<ScanFilter> getPresenceScanFilters() {
+        synchronized (mLock) {
+            List<ScanFilter> scanFilters = new ArrayList();
+            for (IBinder listenerBinder : mScanTypeScanListenerRecordMap.keySet()) {
+                ScanListenerRecord record = mScanTypeScanListenerRecordMap.get(listenerBinder);
+                List<ScanFilter> presenceFilters =
+                        record.getScanRequest().getScanFilters().stream()
+                                .filter(
+                                        scanFilter ->
+                                                scanFilter.getType() == SCAN_TYPE_NEARBY_PRESENCE)
+                                .collect(Collectors.toList());
+                scanFilters.addAll(presenceFilters);
+            }
+            return scanFilters;
+        }
+    }
+
+    private void stopProviders() {
+        stopBleProvider();
+        stopChreProvider();
+    }
+
+    private void stopBleProvider() {
+        mBleDiscoveryProvider.getController().stop();
+    }
+
+    @VisibleForTesting
+    protected void stopChreProvider() {
+        mChreDiscoveryProvider.getController().stop();
+    }
+
+    @VisibleForTesting
+    void invalidateProviderScanMode() {
+        if (mBleDiscoveryProvider.getController().isStarted()) {
+            mBleDiscoveryProvider.getController().setProviderScanMode(mScanMode);
+        } else {
+            Log.d(
+                    TAG,
+                    "Skip invalidating BleDiscoveryProvider scan mode because the provider not "
+                            + "started.");
+        }
+    }
+
+    @VisibleForTesting
+    static class ScanListenerRecord {
+
+        private final ScanRequest mScanRequest;
+
+        private final IScanListener mScanListener;
+
+        private final CallerIdentity mCallerIdentity;
+
+        private final ScanListenerDeathRecipient mDeathRecipient;
+
+        ScanListenerRecord(ScanRequest scanRequest, IScanListener iScanListener,
+                CallerIdentity callerIdentity, ScanListenerDeathRecipient deathRecipient) {
+            mScanListener = iScanListener;
+            mScanRequest = scanRequest;
+            mCallerIdentity = callerIdentity;
+            mDeathRecipient = deathRecipient;
+        }
+
+        IScanListener getScanListener() {
+            return mScanListener;
+        }
+
+        ScanRequest getScanRequest() {
+            return mScanRequest;
+        }
+
+        CallerIdentity getCallerIdentity() {
+            return mCallerIdentity;
+        }
+
+        ScanListenerDeathRecipient getDeathRecipient() {
+            return mDeathRecipient;
+        }
+
+        @Override
+        public boolean equals(Object other) {
+            if (other instanceof ScanListenerRecord) {
+                ScanListenerRecord otherScanListenerRecord = (ScanListenerRecord) other;
+                return Objects.equals(mScanRequest, otherScanListenerRecord.mScanRequest)
+                        && Objects.equals(mScanListener, otherScanListenerRecord.mScanListener);
+            }
+            return false;
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(mScanListener, mScanRequest);
+        }
+    }
+
+    /**
+     * Class to make listener unregister after the binder is dead.
+     */
+    public class ScanListenerDeathRecipient implements IBinder.DeathRecipient {
+        public IScanListener listener;
+
+        ScanListenerDeathRecipient(IScanListener listener) {
+            this.listener = listener;
+        }
+
+        @Override
+        public void binderDied() {
+            Log.d(TAG, "Binder is dead - unregistering scan listener");
+            unregisterScanListener(listener);
+        }
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/managers/ListenerMultiplexer.java b/nearby/service/java/com/android/server/nearby/managers/ListenerMultiplexer.java
new file mode 100644
index 0000000..a6a9388
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/managers/ListenerMultiplexer.java
@@ -0,0 +1,189 @@
+/*
+ * 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.nearby.managers;
+
+import static com.android.server.nearby.NearbyService.TAG;
+
+import android.annotation.NonNull;
+import android.os.IBinder;
+import android.util.ArrayMap;
+import android.util.Log;
+
+import com.android.internal.annotations.GuardedBy;
+import com.android.server.nearby.managers.registration.BinderListenerRegistration;
+import com.android.server.nearby.managers.registration.BinderListenerRegistration.ListenerOperation;
+
+import java.util.Collection;
+import java.util.Objects;
+import java.util.function.Function;
+
+/**
+ * A simplified class based on {@link com.android.server.location.listeners.ListenerMultiplexer}.
+ * It is a base class to multiplex broadcast and discovery events to multiple listener
+ * registrations. Every listener is represented by a registration object which stores all required
+ * state for a listener.
+ * Registrations will be merged to one request for the service to operate.
+ *
+ * @param <TListener>           callback type for clients
+ * @param <TRegistration>       child of {@link BinderListenerRegistration}
+ * @param <TMergedRegistration> merged registration type
+ */
+public abstract class ListenerMultiplexer<TListener,
+        TRegistration extends BinderListenerRegistration<TListener>, TMergedRegistration> {
+
+    /**
+     * The lock object used by the multiplexer. Acquiring this lock allows for multiple operations
+     * on the multiplexer to be completed atomically. Otherwise, it is not required to hold this
+     * lock. This lock is held while invoking all lifecycle callbacks on both the multiplexer and
+     * any registrations.
+     */
+    public final Object mMultiplexerLock = new Object();
+
+    @GuardedBy("mMultiplexerLock")
+    final ArrayMap<IBinder, TRegistration> mRegistrations = new ArrayMap<>();
+
+    // this is really @NonNull in many ways, but we explicitly null this out to allow for GC when
+    // not
+    // in use, so we can't annotate with @NonNull
+    @GuardedBy("mMultiplexerLock")
+    public TMergedRegistration mMerged;
+
+    /**
+     * Invoked when the multiplexer goes from having no registrations to having some registrations.
+     * This is a convenient entry point for registering listeners, etc, which only need to be
+     * present
+     * while there are any registrations. Invoked while holding the multiplexer's internal lock.
+     */
+    @GuardedBy("mMultiplexerLock")
+    public void onRegister() {
+        Log.v(TAG, "ListenerMultiplexer registered.");
+    }
+
+    /**
+     * Invoked when the multiplexer goes from having some registrations to having no registrations.
+     * This is a convenient entry point for unregistering listeners, etc, which only need to be
+     * present while there are any registrations. Invoked while holding the multiplexer's internal
+     * lock.
+     */
+    @GuardedBy("mMultiplexerLock")
+    public void onUnregister() {
+        Log.v(TAG, "ListenerMultiplexer unregistered.");
+    }
+
+    /**
+     * Puts a new registration with the given key, replacing any previous registration under the
+     * same key. This method cannot be called to put a registration re-entrantly.
+     */
+    public final void putRegistration(@NonNull IBinder key, @NonNull TRegistration registration) {
+        Objects.requireNonNull(key);
+        Objects.requireNonNull(registration);
+        synchronized (mMultiplexerLock) {
+            boolean wasEmpty = mRegistrations.isEmpty();
+
+            int index = mRegistrations.indexOfKey(key);
+            if (index > 0) {
+                BinderListenerRegistration<TListener> oldRegistration = mRegistrations.valueAt(
+                        index);
+                oldRegistration.onUnregister();
+                mRegistrations.setValueAt(index, registration);
+            } else {
+                mRegistrations.put(key, registration);
+            }
+
+            registration.onRegister();
+            onRegistrationsUpdated();
+            if (wasEmpty) {
+                onRegister();
+            }
+        }
+    }
+
+    /**
+     * Removes the registration with the given key.
+     */
+    public final void removeRegistration(IBinder key) {
+        synchronized (mMultiplexerLock) {
+            int index = mRegistrations.indexOfKey(key);
+            if (index < 0) {
+                return;
+            }
+
+            removeRegistration(index);
+        }
+    }
+
+    @GuardedBy("mMultiplexerLock")
+    private void removeRegistration(int index) {
+        TRegistration registration = mRegistrations.valueAt(index);
+
+        registration.onUnregister();
+        mRegistrations.removeAt(index);
+
+        onRegistrationsUpdated();
+
+        if (mRegistrations.isEmpty()) {
+            onUnregister();
+        }
+    }
+
+    /**
+     * Invoked when a registration is added, removed, or replaced. Invoked while holding the
+     * multiplexer's internal lock.
+     */
+    @GuardedBy("mMultiplexerLock")
+    public final void onRegistrationsUpdated() {
+        TMergedRegistration newMerged = mergeRegistrations(mRegistrations.values());
+        if (newMerged.equals(mMerged)) {
+            return;
+        }
+        mMerged = newMerged;
+        onMergedRegistrationsUpdated();
+    }
+
+    /**
+     * Called in order to generate a merged registration from the given set of active registrations.
+     * The list of registrations will never be empty. If the resulting merged registration is equal
+     * to the currently registered merged registration, nothing further will happen. If the merged
+     * registration differs,{@link #onMergedRegistrationsUpdated()} will be invoked with the new
+     * merged registration so that the backing service can be updated.
+     */
+    @GuardedBy("mMultiplexerLock")
+    public abstract TMergedRegistration mergeRegistrations(
+            @NonNull Collection<TRegistration> registrations);
+
+    /**
+     * The operation that the manager wants to handle when there is an update for the merged
+     * registration.
+     */
+    @GuardedBy("mMultiplexerLock")
+    public abstract void onMergedRegistrationsUpdated();
+
+    protected final void deliverToListeners(
+            Function<TRegistration, ListenerOperation<TListener>> function) {
+        synchronized (mMultiplexerLock) {
+            final int size = mRegistrations.size();
+            for (int i = 0; i < size; i++) {
+                TRegistration registration = mRegistrations.valueAt(i);
+                BinderListenerRegistration.ListenerOperation<TListener> operation = function.apply(
+                        registration);
+                if (operation != null) {
+                    registration.executeOperation(operation);
+                }
+            }
+        }
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/managers/MergedDiscoveryRequest.java b/nearby/service/java/com/android/server/nearby/managers/MergedDiscoveryRequest.java
new file mode 100644
index 0000000..dcfb602
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/managers/MergedDiscoveryRequest.java
@@ -0,0 +1,162 @@
+/*
+ * 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.nearby.managers;
+
+import android.annotation.IntDef;
+import android.nearby.ScanFilter;
+import android.nearby.ScanRequest;
+import android.util.ArraySet;
+
+import com.google.common.collect.ImmutableSet;
+
+import java.util.Collection;
+import java.util.Set;
+
+/** Internal discovery request to {@link DiscoveryProviderManager} and providers */
+public class MergedDiscoveryRequest {
+
+    private static final MergedDiscoveryRequest EMPTY_REQUEST = new MergedDiscoveryRequest(
+            /* scanMode= */ ScanRequest.SCAN_MODE_NO_POWER,
+            /* scanTypes= */ ImmutableSet.of(),
+            /* actions= */ ImmutableSet.of(),
+            /* scanFilters= */ ImmutableSet.of(),
+            /* mediums= */ ImmutableSet.of());
+    @ScanRequest.ScanMode
+    private final int mScanMode;
+    private final Set<Integer> mScanTypes;
+    private final Set<Integer> mActions;
+    private final Set<ScanFilter> mScanFilters;
+    private final Set<Integer> mMediums;
+
+    private MergedDiscoveryRequest(@ScanRequest.ScanMode int scanMode, Set<Integer> scanTypes,
+            Set<Integer> actions, Set<ScanFilter> scanFilters, Set<Integer> mediums) {
+        mScanMode = scanMode;
+        mScanTypes = scanTypes;
+        mActions = actions;
+        mScanFilters = scanFilters;
+        mMediums = mediums;
+    }
+
+    /**
+     * Returns an empty discovery request.
+     *
+     * <p>The empty request is used as the default request when the discovery engine is enabled,
+     * but
+     * there is no request yet. It's also used to notify the discovery engine all clients have
+     * removed
+     * their requests.
+     */
+    public static MergedDiscoveryRequest empty() {
+        return EMPTY_REQUEST;
+    }
+
+    /** Returns the priority of the request */
+    @ScanRequest.ScanMode
+    public final int getScanMode() {
+        return mScanMode;
+    }
+
+    /** Returns all requested scan types. */
+    public ImmutableSet<Integer> getScanTypes() {
+        return ImmutableSet.copyOf(mScanTypes);
+    }
+
+    /** Returns the actions of the request */
+    public ImmutableSet<Integer> getActions() {
+        return ImmutableSet.copyOf(mActions);
+    }
+
+    /** Returns the scan filters of the request */
+    public ImmutableSet<ScanFilter> getScanFilters() {
+        return ImmutableSet.copyOf(mScanFilters);
+    }
+
+    /** Returns the enabled scan mediums */
+    public ImmutableSet<Integer> getMediums() {
+        return ImmutableSet.copyOf(mMediums);
+    }
+
+    /**
+     * The medium where the broadcast request should be sent.
+     *
+     * @hide
+     */
+    @IntDef({Medium.BLE})
+    public @interface Medium {
+        int BLE = 1;
+    }
+
+    /** Builder for {@link MergedDiscoveryRequest}. */
+    public static class Builder {
+        private final Set<Integer> mScanTypes;
+        private final Set<Integer> mActions;
+        private final Set<ScanFilter> mScanFilters;
+        private final Set<Integer> mMediums;
+        @ScanRequest.ScanMode
+        private int mScanMode;
+
+        public Builder() {
+            mScanMode = ScanRequest.SCAN_MODE_NO_POWER;
+            mScanTypes = new ArraySet<>();
+            mActions = new ArraySet<>();
+            mScanFilters = new ArraySet<>();
+            mMediums = new ArraySet<>();
+        }
+
+        /**
+         * Sets the priority for the engine request.
+         */
+        public Builder setScanMode(@ScanRequest.ScanMode int scanMode) {
+            mScanMode = scanMode;
+            return this;
+        }
+
+        /**
+         * Adds scan type to the request.
+         */
+        public Builder addScanType(@ScanRequest.ScanType int type) {
+            mScanTypes.add(type);
+            return this;
+        }
+
+        /** Add actions to the request. */
+        public Builder addActions(Collection<Integer> actions) {
+            mActions.addAll(actions);
+            return this;
+        }
+
+        /** Add actions to the request. */
+        public Builder addScanFilters(Collection<ScanFilter> scanFilters) {
+            mScanFilters.addAll(scanFilters);
+            return this;
+        }
+
+        /**
+         * Add mediums to the request.
+         */
+        public Builder addMedium(@Medium int medium) {
+            mMediums.add(medium);
+            return this;
+        }
+
+        /** Builds an instance of {@link MergedDiscoveryRequest}. */
+        public MergedDiscoveryRequest build() {
+            return new MergedDiscoveryRequest(mScanMode, mScanTypes, mActions, mScanFilters,
+                    mMediums);
+        }
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/managers/registration/BinderListenerRegistration.java b/nearby/service/java/com/android/server/nearby/managers/registration/BinderListenerRegistration.java
new file mode 100644
index 0000000..4aaa08f
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/managers/registration/BinderListenerRegistration.java
@@ -0,0 +1,208 @@
+/*
+ * 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.nearby.managers.registration;
+
+import static com.android.server.nearby.NearbyService.TAG;
+
+import android.annotation.Nullable;
+import android.os.IBinder;
+import android.os.RemoteException;
+import android.util.Log;
+
+import com.android.server.nearby.managers.ListenerMultiplexer;
+
+import java.util.NoSuchElementException;
+import java.util.concurrent.Executor;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * A listener registration object which holds data associated with the listener, such as an optional
+ * request, and an executor responsible for listener invocations. Key is the IBinder.
+ *
+ * @param <TListener> listener for the callback
+ */
+public abstract class BinderListenerRegistration<TListener> implements IBinder.DeathRecipient {
+
+    private final AtomicBoolean mRemoved = new AtomicBoolean(false);
+    private final Executor mExecutor;
+    private final Object mListenerLock = new Object();
+    @Nullable
+    TListener mListener;
+    @Nullable
+    private final IBinder mKey;
+
+    public BinderListenerRegistration(IBinder key, Executor executor, TListener listener) {
+        this.mKey = key;
+        this.mExecutor = executor;
+        this.mListener = listener;
+    }
+
+    /**
+     * Must be implemented to return the
+     * {@link com.android.server.nearby.managers.ListenerMultiplexer} this registration is
+     * registered
+     * with. Often this is easiest to accomplish by defining registration subclasses as non-static
+     * inner classes of the multiplexer they are to be used with.
+     */
+    public abstract ListenerMultiplexer<TListener, ?
+            extends BinderListenerRegistration<TListener>, ?> getOwner();
+
+    public final IBinder getBinder() {
+        return mKey;
+    }
+
+    public final Executor getExecutor() {
+        return mExecutor;
+    }
+
+    /**
+     * Called when the registration is put in the Multiplexer.
+     */
+    public void onRegister() {
+        try {
+            getBinder().linkToDeath(this, 0);
+        } catch (RemoteException e) {
+            remove();
+        }
+    }
+
+    /**
+     * Called when the registration is removed in the Multiplexer.
+     */
+    public void onUnregister() {
+        this.mListener = null;
+        try {
+            getBinder().unlinkToDeath(this, 0);
+        } catch (NoSuchElementException e) {
+            Log.w(TAG, "failed to unregister binder death listener", e);
+        }
+    }
+
+    /**
+     * Removes this registration. All pending listener invocations will fail.
+     *
+     * <p>Does nothing if invoked before {@link #onRegister()} or after {@link #onUnregister()}.
+     */
+    public final void remove() {
+        IBinder key = mKey;
+        if (key != null && !mRemoved.getAndSet(true)) {
+            getOwner().removeRegistration(key);
+        }
+    }
+
+    @Override
+    public void binderDied() {
+        remove();
+    }
+
+    /**
+     * May be overridden by subclasses to handle listener operation failures. The default behavior
+     * is
+     * to further propagate any exceptions. Will always be invoked on the executor thread.
+     */
+    protected void onOperationFailure(Exception exception) {
+        throw new AssertionError(exception);
+    }
+
+    /**
+     * Executes the given listener operation on the registration executor, invoking {@link
+     * #onOperationFailure(Exception)} in case the listener operation fails. If the registration is
+     * removed prior to the operation running, the operation is considered canceled. If a null
+     * operation is supplied, nothing happens.
+     */
+    public final void executeOperation(@Nullable ListenerOperation<TListener> operation) {
+        if (operation == null) {
+            return;
+        }
+
+        synchronized (mListenerLock) {
+            if (mListener == null) {
+                return;
+            }
+
+            AtomicBoolean complete = new AtomicBoolean(false);
+            mExecutor.execute(() -> {
+                TListener listener;
+                synchronized (mListenerLock) {
+                    listener = mListener;
+                }
+
+                Exception failure = null;
+                if (listener != null) {
+                    try {
+                        operation.operate(listener);
+                    } catch (Exception e) {
+                        if (e instanceof RuntimeException) {
+                            throw (RuntimeException) e;
+                        } else {
+                            failure = e;
+                        }
+                    }
+                }
+
+                operation.onComplete(failure == null);
+                complete.set(true);
+
+                if (failure != null) {
+                    onOperationFailure(failure);
+                }
+            });
+            operation.onScheduled(complete.get());
+        }
+    }
+
+    /**
+     * An listener operation to perform.
+     *
+     * @param <ListenerT> listener type
+     */
+    public interface ListenerOperation<ListenerT> {
+
+        /**
+         * Invoked after the operation has been scheduled for execution. The {@code complete}
+         * argument
+         * will be true if {@link #onComplete(boolean)} was invoked prior to this callback (such as
+         * if
+         * using a direct executor), or false if {@link #onComplete(boolean)} will be invoked after
+         * this
+         * callback. This method is always invoked on the calling thread.
+         */
+        default void onScheduled(boolean complete) {
+        }
+
+        /**
+         * Invoked to perform an operation on the given listener. This method is always invoked on
+         * the
+         * executor thread. If this method throws a checked exception, the operation will fail and
+         * result in {@link #onOperationFailure(Exception)} being invoked. If this method throws an
+         * unchecked exception, this propagates normally and should result in a crash.
+         */
+        void operate(ListenerT listener) throws Exception;
+
+        /**
+         * Invoked after the operation is complete. The {@code success} argument will be true if
+         * the
+         * operation completed without throwing any exceptions, and false otherwise (such as if the
+         * operation was canceled prior to executing, or if it threw an exception). This invocation
+         * may
+         * happen either before or after (but never during) the invocation of {@link
+         * #onScheduled(boolean)}. This method is always invoked on the executor thread.
+         */
+        default void onComplete(boolean success) {
+        }
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/managers/registration/DiscoveryRegistration.java b/nearby/service/java/com/android/server/nearby/managers/registration/DiscoveryRegistration.java
new file mode 100644
index 0000000..91237d2
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/managers/registration/DiscoveryRegistration.java
@@ -0,0 +1,362 @@
+/*
+ * 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.nearby.managers.registration;
+
+import static android.nearby.ScanRequest.SCAN_TYPE_NEARBY_PRESENCE;
+
+import static com.android.server.nearby.NearbyService.TAG;
+
+import android.annotation.IntDef;
+import android.annotation.Nullable;
+import android.app.AppOpsManager;
+import android.nearby.IScanListener;
+import android.nearby.NearbyDeviceParcelable;
+import android.nearby.PresenceScanFilter;
+import android.nearby.ScanCallback;
+import android.nearby.ScanFilter;
+import android.nearby.ScanRequest;
+import android.util.ArrayMap;
+import android.util.ArraySet;
+import android.util.Log;
+
+import com.android.internal.annotations.GuardedBy;
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.server.nearby.common.CancelableAlarm;
+import com.android.server.nearby.managers.ListenerMultiplexer;
+import com.android.server.nearby.managers.MergedDiscoveryRequest;
+import com.android.server.nearby.presence.PresenceDiscoveryResult;
+import com.android.server.nearby.util.identity.CallerIdentity;
+import com.android.server.nearby.util.permissions.DiscoveryPermissions;
+
+import com.google.common.collect.ImmutableSet;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.stream.Collectors;
+
+/**
+ * Class responsible for all client based operations. Each {@link DiscoveryRegistration} is for one
+ * valid unique {@link android.nearby.NearbyManager#startScan(ScanRequest, Executor, ScanCallback)}
+ */
+public class DiscoveryRegistration extends BinderListenerRegistration<IScanListener> {
+
+    /**
+     * Timeout before a previous discovered device is reported as lost.
+     */
+    @VisibleForTesting
+    static final int ON_LOST_TIME_OUT_MS = 10000;
+    /** Lock for registration operations. */
+    final Object mMultiplexerLock;
+    private final ListenerMultiplexer<IScanListener, DiscoveryRegistration, MergedDiscoveryRequest>
+            mOwner;
+    private final AppOpsManager mAppOpsManager;
+    /** Presence devices that are currently discovered, and not lost yet. */
+    @GuardedBy("mMultiplexerLock")
+    private final Map<Long, NearbyDeviceParcelable> mDiscoveredDevices;
+    /** A map of deviceId and alarms for reporting device lost. */
+    @GuardedBy("mMultiplexerLock")
+    private final Map<Long, DeviceOnLostAlarm> mDiscoveryOnLostAlarmPerDevice = new ArrayMap<>();
+    /**
+     * The single thread executor to run {@link CancelableAlarm} to report
+     * {@link NearbyDeviceParcelable} on lost after timeout.
+     */
+    private final ScheduledExecutorService mAlarmExecutor =
+            Executors.newSingleThreadScheduledExecutor();
+    private final ScanRequest mScanRequest;
+    private final CallerIdentity mCallerIdentity;
+
+    public DiscoveryRegistration(
+            ListenerMultiplexer<IScanListener, DiscoveryRegistration, MergedDiscoveryRequest> owner,
+            ScanRequest scanRequest, IScanListener scanListener, Executor executor,
+            CallerIdentity callerIdentity, Object multiplexerLock, AppOpsManager appOpsManager) {
+        super(scanListener.asBinder(), executor, scanListener);
+        mOwner = owner;
+        mListener = scanListener;
+        mScanRequest = scanRequest;
+        mCallerIdentity = callerIdentity;
+        mMultiplexerLock = multiplexerLock;
+        mDiscoveredDevices = new ArrayMap<>();
+        mAppOpsManager = appOpsManager;
+    }
+
+    /**
+     * Gets the scan request.
+     */
+    public ScanRequest getScanRequest() {
+        return mScanRequest;
+    }
+
+    /**
+     * Gets the actions from the scan filter(s).
+     */
+    public Set<Integer> getActions() {
+        Set<Integer> result = new ArraySet<>();
+        List<ScanFilter> filters = mScanRequest.getScanFilters();
+        for (ScanFilter filter : filters) {
+            if (filter instanceof PresenceScanFilter) {
+                result.addAll(((PresenceScanFilter) filter).getPresenceActions());
+            }
+        }
+        return ImmutableSet.copyOf(result);
+    }
+
+    /**
+     * Gets all the filters that are for Nearby Presence.
+     */
+    public Set<ScanFilter> getPresenceScanFilters() {
+        Set<ScanFilter> result = new ArraySet<>();
+        List<ScanFilter> filters = mScanRequest.getScanFilters();
+        for (ScanFilter filter : filters) {
+            if (filter.getType() == SCAN_TYPE_NEARBY_PRESENCE) {
+                result.add(filter);
+            }
+        }
+        return ImmutableSet.copyOf(result);
+    }
+
+    @VisibleForTesting
+    Map<Long, DeviceOnLostAlarm> getDiscoveryOnLostAlarms() {
+        synchronized (mMultiplexerLock) {
+            return mDiscoveryOnLostAlarmPerDevice;
+        }
+    }
+
+    @Override
+    public boolean equals(Object other) {
+        if (other instanceof DiscoveryRegistration) {
+            DiscoveryRegistration otherRegistration = (DiscoveryRegistration) other;
+            return Objects.equals(mScanRequest, otherRegistration.mScanRequest) && Objects.equals(
+                    mListener, otherRegistration.mListener);
+        }
+        return false;
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(mListener, mScanRequest);
+    }
+
+    @Override
+    public ListenerMultiplexer<
+            IScanListener, DiscoveryRegistration, MergedDiscoveryRequest> getOwner() {
+        return mOwner;
+    }
+
+    @VisibleForTesting
+    ListenerOperation<IScanListener> reportDeviceLost(NearbyDeviceParcelable device) {
+        long deviceId = device.getDeviceId();
+        return reportResult(DiscoveryResult.DEVICE_LOST, device, () -> {
+            synchronized (mMultiplexerLock) {
+                // Remove the device from reporting devices after reporting lost.
+                mDiscoveredDevices.remove(deviceId);
+                DeviceOnLostAlarm alarm = mDiscoveryOnLostAlarmPerDevice.remove(deviceId);
+                if (alarm != null) {
+                    alarm.cancel();
+                }
+            }
+        });
+    }
+
+    /**
+     * Called when there is device discovered from the server.
+     */
+    public ListenerOperation<IScanListener> onNearbyDeviceDiscovered(
+            NearbyDeviceParcelable device) {
+        if (!filterCheck(device)) {
+            Log.d(TAG, "presence filter does not match for the scanned Presence Device");
+            return null;
+        }
+        synchronized (mMultiplexerLock) {
+            long deviceId = device.getDeviceId();
+            boolean deviceReported = mDiscoveredDevices.containsKey(deviceId);
+            scheduleOnLostAlarm(device);
+            if (deviceReported) {
+                NearbyDeviceParcelable oldDevice = mDiscoveredDevices.get(deviceId);
+                if (device.equals(oldDevice)) {
+                    return null;
+                }
+                return reportUpdated(device);
+            }
+            return reportDiscovered(device);
+        }
+    }
+
+    @VisibleForTesting
+    static boolean presenceFilterMatches(NearbyDeviceParcelable device,
+            List<ScanFilter> scanFilters) {
+        if (scanFilters.isEmpty()) {
+            return true;
+        }
+        PresenceDiscoveryResult discoveryResult = PresenceDiscoveryResult.fromDevice(device);
+        for (ScanFilter scanFilter : scanFilters) {
+            PresenceScanFilter presenceScanFilter = (PresenceScanFilter) scanFilter;
+            if (discoveryResult.matches(presenceScanFilter)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    @Nullable
+    ListenerOperation<IScanListener> reportDiscovered(NearbyDeviceParcelable device) {
+        long deviceId = device.getDeviceId();
+        return reportResult(DiscoveryResult.DEVICE_DISCOVERED, device, () -> {
+            synchronized (mMultiplexerLock) {
+                // Add the device to discovered devices after reporting device is
+                // discovered.
+                mDiscoveredDevices.put(deviceId, device);
+                scheduleOnLostAlarm(device);
+            }
+        });
+    }
+
+    @Nullable
+    ListenerOperation<IScanListener> reportUpdated(NearbyDeviceParcelable device) {
+        long deviceId = device.getDeviceId();
+        return reportResult(DiscoveryResult.DEVICE_UPDATED, device, () -> {
+            synchronized (mMultiplexerLock) {
+                // Update the new device to discovered devices after reporting device is
+                // discovered.
+                mDiscoveredDevices.put(deviceId, device);
+                scheduleOnLostAlarm(device);
+            }
+        });
+
+    }
+
+    /** Reports an error to the client. */
+    public ListenerOperation<IScanListener> reportError(@ScanCallback.ErrorCode int errorCode) {
+        return listener -> listener.onError(errorCode);
+    }
+
+    @Nullable
+    ListenerOperation<IScanListener> reportResult(@DiscoveryResult int result,
+            NearbyDeviceParcelable device, @Nullable Runnable successReportCallback) {
+        // Report the operation to AppOps.
+        // NOTE: AppOps report has to be the last operation before delivering the result. Otherwise
+        // we may over-report when the discovery result doesn't end up being delivered.
+        if (!checkIdentity()) {
+            return reportError(ScanCallback.ERROR_PERMISSION_DENIED);
+        }
+
+        return new ListenerOperation<>() {
+
+            @Override
+            public void operate(IScanListener listener) throws Exception {
+                switch (result) {
+                    case DiscoveryResult.DEVICE_DISCOVERED:
+                        listener.onDiscovered(device);
+                        break;
+                    case DiscoveryResult.DEVICE_UPDATED:
+                        listener.onUpdated(device);
+                        break;
+                    case DiscoveryResult.DEVICE_LOST:
+                        listener.onLost(device);
+                        break;
+                }
+            }
+
+            @Override
+            public void onComplete(boolean success) {
+                if (success) {
+                    if (successReportCallback != null) {
+                        successReportCallback.run();
+                        Log.d(TAG, "Successfully delivered result to caller.");
+                    }
+                }
+            }
+        };
+    }
+
+    private boolean filterCheck(NearbyDeviceParcelable device) {
+        if (device.getScanType() != SCAN_TYPE_NEARBY_PRESENCE) {
+            return true;
+        }
+        List<ScanFilter> presenceFilters = mScanRequest.getScanFilters().stream().filter(
+                scanFilter -> scanFilter.getType() == SCAN_TYPE_NEARBY_PRESENCE).collect(
+                Collectors.toList());
+        return presenceFilterMatches(device, presenceFilters);
+    }
+
+    private boolean checkIdentity() {
+        boolean result = DiscoveryPermissions.noteDiscoveryResultDelivery(mAppOpsManager,
+                mCallerIdentity);
+        if (!result) {
+            Log.w(TAG, "[DiscoveryProviderManager] scan permission revoked "
+                    + "- not forwarding results for the registration.");
+        }
+        return result;
+    }
+
+    @GuardedBy("mMultiplexerLock")
+    private void scheduleOnLostAlarm(NearbyDeviceParcelable device) {
+        long deviceId = device.getDeviceId();
+        DeviceOnLostAlarm alarm = mDiscoveryOnLostAlarmPerDevice.get(deviceId);
+        if (alarm == null) {
+            alarm = new DeviceOnLostAlarm(device, mAlarmExecutor);
+            mDiscoveryOnLostAlarmPerDevice.put(deviceId, alarm);
+        }
+        alarm.start();
+        Log.d(TAG, "DiscoveryProviderManager updated state for " + device.getDeviceId());
+    }
+
+    /** Status of the discovery result. */
+    @IntDef({DiscoveryResult.DEVICE_DISCOVERED, DiscoveryResult.DEVICE_UPDATED,
+            DiscoveryResult.DEVICE_LOST})
+    public @interface DiscoveryResult {
+        int DEVICE_DISCOVERED = 0;
+        int DEVICE_UPDATED = 1;
+        int DEVICE_LOST = 2;
+    }
+
+    private class DeviceOnLostAlarm {
+
+        private static final String NAME = "DeviceOnLostAlarm";
+        private final NearbyDeviceParcelable mDevice;
+        private final ScheduledExecutorService mAlarmExecutor;
+        @Nullable
+        private CancelableAlarm mTimeoutAlarm;
+
+        DeviceOnLostAlarm(NearbyDeviceParcelable device, ScheduledExecutorService alarmExecutor) {
+            mDevice = device;
+            mAlarmExecutor = alarmExecutor;
+        }
+
+        synchronized void start() {
+            cancel();
+            this.mTimeoutAlarm = CancelableAlarm.createSingleAlarm(NAME, () -> {
+                Log.d(TAG, String.format("%s timed out after %d ms. Reporting %s on lost.", NAME,
+                        ON_LOST_TIME_OUT_MS, mDevice.getName()));
+                synchronized (mMultiplexerLock) {
+                    executeOperation(reportDeviceLost(mDevice));
+                }
+            }, ON_LOST_TIME_OUT_MS, mAlarmExecutor);
+        }
+
+        synchronized void cancel() {
+            if (mTimeoutAlarm != null) {
+                mTimeoutAlarm.cancel();
+                mTimeoutAlarm = null;
+            }
+        }
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/presence/Advertisement.java b/nearby/service/java/com/android/server/nearby/presence/Advertisement.java
new file mode 100644
index 0000000..d42f6c7
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/presence/Advertisement.java
@@ -0,0 +1,74 @@
+/*
+ * 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.nearby.presence;
+
+import android.annotation.Nullable;
+import android.nearby.BroadcastRequest;
+import android.nearby.PresenceCredential;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/** A Nearby Presence advertisement to be advertised. */
+public abstract class Advertisement {
+
+    @BroadcastRequest.BroadcastVersion
+    int mVersion = BroadcastRequest.PRESENCE_VERSION_UNKNOWN;
+    int mLength;
+    @PresenceCredential.IdentityType int mIdentityType;
+    byte[] mIdentity;
+    byte[] mSalt;
+    List<Integer> mActions;
+
+    /** Serialize an {@link Advertisement} object into bytes. */
+    @Nullable
+    public byte[] toBytes() {
+        return new byte[0];
+    }
+
+    /** Returns the length of the advertisement. */
+    public int getLength() {
+        return mLength;
+    }
+
+    /** Returns the version in the advertisement. */
+    @BroadcastRequest.BroadcastVersion
+    public int getVersion() {
+        return mVersion;
+    }
+
+    /** Returns the identity type in the advertisement. */
+    @PresenceCredential.IdentityType
+    public int getIdentityType() {
+        return mIdentityType;
+    }
+
+    /** Returns the identity bytes in the advertisement. */
+    public byte[] getIdentity() {
+        return mIdentity.clone();
+    }
+
+    /** Returns the salt of the advertisement. */
+    public byte[] getSalt() {
+        return mSalt.clone();
+    }
+
+    /** Returns the actions in the advertisement. */
+    public List<Integer> getActions() {
+        return new ArrayList<>(mActions);
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/presence/DataElementHeader.java b/nearby/service/java/com/android/server/nearby/presence/DataElementHeader.java
new file mode 100644
index 0000000..ae4a728
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/presence/DataElementHeader.java
@@ -0,0 +1,266 @@
+/*
+ * 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.nearby.presence;
+
+import android.annotation.Nullable;
+import android.nearby.BroadcastRequest;
+import android.nearby.DataElement;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.internal.util.Preconditions;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+import javax.annotation.Nonnull;
+
+/**
+ * Represents a data element header in Nearby Presence.
+ * Each header has 3 parts: tag, length and style.
+ * Tag: 1 bit (MSB at each byte). 1 for extending, which means there will be more bytes after
+ * the current one for the header.
+ * Length: The total length of a Data Element field. Length is up to 127 and is limited within
+ * the entire first byte in the header. (7 bits, MSB is the tag).
+ * Type: Represents {@link DataElement.DataType}. There is no limit for the type number.
+ *
+ * @hide
+ */
+public class DataElementHeader {
+    // Each Data reserved MSB for tag.
+    static final int TAG_BITMASK = 0b10000000;
+    static final int TAG_OFFSET = 7;
+
+    // If the header is only 1 byte, it has the format: 0b0LLLTTTT. (L for length, T for type.)
+    static final int SINGLE_AVAILABLE_LENGTH_BIT = 3;
+    static final int SINGLE_AVAILABLE_TYPE_BIT = 4;
+    static final int SINGLE_LENGTH_BITMASK = 0b01110000;
+    static final int SINGLE_LENGTH_OFFSET = SINGLE_AVAILABLE_TYPE_BIT;
+    static final int SINGLE_TYPE_BITMASK = 0b00001111;
+
+    // If there are multiple data element headers.
+    // First byte is always the length.
+    static final int MULTIPLE_LENGTH_BYTE = 1;
+    // Each byte reserves MSB for tag.
+    static final int MULTIPLE_BITMASK = 0b01111111;
+
+    @BroadcastRequest.BroadcastVersion
+    private final int mVersion;
+    @DataElement.DataType
+    private final int mDataType;
+    private final int mDataLength;
+
+    DataElementHeader(@BroadcastRequest.BroadcastVersion int version,
+            @DataElement.DataType int dataType, int dataLength) {
+        Preconditions.checkArgument(version == BroadcastRequest.PRESENCE_VERSION_V1,
+                "DataElementHeader is only supported in V1.");
+        Preconditions.checkArgument(dataLength >= 0, "Length should not be negative.");
+        Preconditions.checkArgument(dataLength < (1 << TAG_OFFSET),
+                "Data element should be equal or shorter than 128.");
+
+        this.mVersion = version;
+        this.mDataType = dataType;
+        this.mDataLength = dataLength;
+    }
+
+    /**
+     * The total type of the data element.
+     */
+    @DataElement.DataType
+    public int getDataType() {
+        return mDataType;
+    }
+
+    /**
+     * The total length of a Data Element field.
+     */
+    public int getDataLength() {
+        return mDataLength;
+    }
+
+    /** Serialize a {@link DataElementHeader} object into bytes. */
+    public byte[] toBytes() {
+        Preconditions.checkState(mVersion == BroadcastRequest.PRESENCE_VERSION_V1,
+                "DataElementHeader is only supported in V1.");
+        // Only 1 byte needed for the header
+        if (mDataType < (1 << SINGLE_AVAILABLE_TYPE_BIT)
+                && mDataLength < (1 << SINGLE_AVAILABLE_LENGTH_BIT)) {
+            return new byte[]{createSingleByteHeader(mDataType, mDataLength)};
+        }
+
+        return createMultipleBytesHeader(mDataType, mDataLength);
+    }
+
+    /** Creates a {@link DataElementHeader} object from bytes. */
+    @Nullable
+    public static DataElementHeader fromBytes(@BroadcastRequest.BroadcastVersion int version,
+            @Nonnull byte[] bytes) {
+        Objects.requireNonNull(bytes, "Data parsed in for DataElement should not be null.");
+
+        if (bytes.length == 0) {
+            return null;
+        }
+
+        if (bytes.length == 1) {
+            if (isExtending(bytes[0])) {
+                throw new IllegalArgumentException("The header is not complete.");
+            }
+            return new DataElementHeader(BroadcastRequest.PRESENCE_VERSION_V1,
+                    getTypeSingleByte(bytes[0]), getLengthSingleByte(bytes[0]));
+        }
+
+        // The first byte should be length and there should be at least 1 more byte following to
+        // represent type.
+        // The last header byte's MSB should be 0.
+        if (!isExtending(bytes[0]) || isExtending(bytes[bytes.length - 1])) {
+            throw new IllegalArgumentException("The header format is wrong.");
+        }
+
+        return new DataElementHeader(version,
+                getTypeMultipleBytes(Arrays.copyOfRange(bytes, 1, bytes.length)),
+                getHeaderValue(bytes[0]));
+    }
+
+    /** Creates a header based on type and length.
+     * This is used when the type is <= 16 and length is <= 7. */
+    static byte createSingleByteHeader(int type, int length) {
+        return (byte) (convertTag(/* extend= */ false)
+                | convertLengthSingleByte(length)
+                | convertTypeSingleByte(type));
+    }
+
+    /** Creates a header based on type and length.
+     * This is used when the type is > 16 or length is > 7. */
+    static byte[] createMultipleBytesHeader(int type, int length) {
+        List<Byte> typeIntList = convertTypeMultipleBytes(type);
+        byte[] res = new byte[typeIntList.size() + MULTIPLE_LENGTH_BYTE];
+        int index = 0;
+        res[index++] = convertLengthMultipleBytes(length);
+
+        for (int typeInt : typeIntList) {
+            res[index++] = (byte) typeInt;
+        }
+        return res;
+    }
+
+    /** Constructs a Data Element header with length indicated in byte format.
+     * The most significant bit is the tag, 2- 4 bits are the length, 5 - 8 bits are the type.
+     */
+    @VisibleForTesting
+    static int convertLengthSingleByte(int length) {
+        Preconditions.checkArgument(length >= 0, "Length should not be negative.");
+        Preconditions.checkArgument(length < (1 << SINGLE_AVAILABLE_LENGTH_BIT),
+                "In single Data Element header, length should be shorter than 8.");
+        return (length << SINGLE_LENGTH_OFFSET) & SINGLE_LENGTH_BITMASK;
+    }
+
+    /** Constructs a Data Element header with type indicated in byte format.
+     * The most significant bit is the tag, 2- 4 bits are the length, 5 - 8 bits are the type.
+     */
+    @VisibleForTesting
+    static int convertTypeSingleByte(int type) {
+        Preconditions.checkArgument(type >= 0, "Type should not be negative.");
+        Preconditions.checkArgument(type < (1 << SINGLE_AVAILABLE_TYPE_BIT),
+                "In single Data Element header, type should be smaller than 16.");
+
+        return type & SINGLE_TYPE_BITMASK;
+    }
+
+    /**
+     * Gets the length of Data Element from the header. (When there is only 1 byte of header)
+     */
+    static int getLengthSingleByte(byte header) {
+        Preconditions.checkArgument(!isExtending(header),
+                "Cannot apply this method for the extending header.");
+        return (header & SINGLE_LENGTH_BITMASK) >> SINGLE_LENGTH_OFFSET;
+    }
+
+    /**
+     * Gets the type of Data Element from the header. (When there is only 1 byte of header)
+     */
+    static int getTypeSingleByte(byte header) {
+        Preconditions.checkArgument(!isExtending(header),
+                "Cannot apply this method for the extending header.");
+        return header & SINGLE_TYPE_BITMASK;
+    }
+
+    /** Creates a DE(data element) header based on length.
+     * This is used when header is more than 1 byte. The first byte is always the length.
+     */
+    static byte convertLengthMultipleBytes(int length) {
+        Preconditions.checkArgument(length < (1 << TAG_OFFSET),
+                "Data element should be equal or shorter than 128.");
+        return (byte) (convertTag(/* extend= */ true) | (length & MULTIPLE_BITMASK));
+    }
+
+    /** Creates a DE(data element) header based on type.
+     * This is used when header is more than 1 byte. The first byte is always the length.
+     */
+    @VisibleForTesting
+    static List<Byte> convertTypeMultipleBytes(int type) {
+        List<Byte> typeBytes = new ArrayList<>();
+        while (type > 0) {
+            byte current = (byte) (type & MULTIPLE_BITMASK);
+            type = type >> TAG_OFFSET;
+            typeBytes.add(current);
+        }
+
+        Collections.reverse(typeBytes);
+        int size = typeBytes.size();
+        // The last byte's MSB should be 0.
+        for (int i = 0; i < size - 1; i++) {
+            typeBytes.set(i, (byte) (convertTag(/* extend= */ true) | typeBytes.get(i)));
+        }
+        return typeBytes;
+    }
+
+    /** Creates a DE(data element) header based on type.
+     * This is used when header is more than 1 byte. The first byte is always the length.
+     * Uses Integer when doing bit operation to avoid error.
+     */
+    @VisibleForTesting
+    static int getTypeMultipleBytes(byte[] typeByteArray) {
+        int type = 0;
+        int size = typeByteArray.length;
+        for (int i = 0; i < size; i++) {
+            type = (type << TAG_OFFSET) | getHeaderValue(typeByteArray[i]);
+        }
+        return type;
+    }
+
+    /** Gets the integer value of the 7 bits in the header. (The MSB is tag) */
+    @VisibleForTesting
+    static int getHeaderValue(byte header) {
+        return (header & MULTIPLE_BITMASK);
+    }
+
+    /** Sets the MSB of the header byte. If this is the last byte of headers, MSB is 0.
+     * If there are at least header following, the MSB is 1.
+     */
+    @VisibleForTesting
+    static byte convertTag(boolean extend) {
+        return (byte) (extend ? 0b10000000 : 0b00000000);
+    }
+
+    /** Returns {@code true} if there are at least 1 byte of header after the current one. */
+    @VisibleForTesting
+    static boolean isExtending(byte header) {
+        return (header & TAG_BITMASK) != 0;
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/presence/ExtendedAdvertisement.java b/nearby/service/java/com/android/server/nearby/presence/ExtendedAdvertisement.java
new file mode 100644
index 0000000..34a7514
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/presence/ExtendedAdvertisement.java
@@ -0,0 +1,409 @@
+/*
+ * 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.nearby.presence;
+
+import static com.android.server.nearby.NearbyService.TAG;
+
+import android.annotation.Nullable;
+import android.nearby.BroadcastRequest;
+import android.nearby.DataElement;
+import android.nearby.PresenceBroadcastRequest;
+import android.nearby.PresenceCredential;
+import android.nearby.PublicCredential;
+import android.util.Log;
+
+import com.android.server.nearby.util.encryption.Cryptor;
+import com.android.server.nearby.util.encryption.CryptorImpFake;
+import com.android.server.nearby.util.encryption.CryptorImpIdentityV1;
+import com.android.server.nearby.util.encryption.CryptorImpV1;
+
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * A Nearby Presence advertisement to be advertised on BT5.0 devices.
+ *
+ * <p>Serializable between Java object and bytes formats. Java object is used at the upper scanning
+ * and advertising interface as an abstraction of the actual bytes. Bytes format is used at the
+ * underlying BLE and mDNS stacks, which do necessary slicing and merging based on advertising
+ * capacities.
+ *
+ * The extended advertisement is defined in the format below:
+ * Header (1 byte) | salt (1+2 bytes) | Identity + filter (2+16 bytes)
+ * | repeated DE fields (various bytes)
+ * The header contains:
+ * version (3 bits) | 5 bit reserved for future use (RFU)
+ */
+public class ExtendedAdvertisement extends Advertisement{
+
+    public static final int SALT_DATA_LENGTH = 2;
+
+    static final int HEADER_LENGTH = 1;
+
+    static final int IDENTITY_DATA_LENGTH = 16;
+
+    private final List<DataElement> mDataElements;
+
+    private final byte[] mAuthenticityKey;
+
+    // All Data Elements including salt and identity.
+    // Each list item (byte array) is a Data Element (with its header).
+    private final List<byte[]> mCompleteDataElementsBytes;
+    // Signature generated from data elements.
+    private final byte[] mHmacTag;
+
+    /**
+     * Creates an {@link ExtendedAdvertisement} from a Presence Broadcast Request.
+     * @return {@link ExtendedAdvertisement} object. {@code null} when the request is illegal.
+     */
+    @Nullable
+    public static ExtendedAdvertisement createFromRequest(PresenceBroadcastRequest request) {
+        if (request.getVersion() != BroadcastRequest.PRESENCE_VERSION_V1) {
+            Log.v(TAG, "ExtendedAdvertisement only supports V1 now.");
+            return null;
+        }
+
+        byte[] salt = request.getSalt();
+        if (salt.length != SALT_DATA_LENGTH) {
+            Log.v(TAG, "Salt does not match correct length");
+            return null;
+        }
+
+        byte[] identity = request.getCredential().getMetadataEncryptionKey();
+        byte[] authenticityKey = request.getCredential().getAuthenticityKey();
+        if (identity.length != IDENTITY_DATA_LENGTH) {
+            Log.v(TAG, "Identity does not match correct length");
+            return null;
+        }
+
+        List<Integer> actions = request.getActions();
+        if (actions.isEmpty()) {
+            Log.v(TAG, "ExtendedAdvertisement must contain at least one action");
+            return null;
+        }
+
+        List<DataElement> dataElements = request.getExtendedProperties();
+        return new ExtendedAdvertisement(
+                request.getCredential().getIdentityType(),
+                identity,
+                salt,
+                authenticityKey,
+                actions,
+                dataElements);
+    }
+
+    /** Serialize an {@link ExtendedAdvertisement} object into bytes with {@link DataElement}s */
+    @Nullable
+    public byte[] toBytes() {
+        ByteBuffer buffer = ByteBuffer.allocate(getLength());
+
+        // Header
+        buffer.put(ExtendedAdvertisementUtils.constructHeader(getVersion()));
+
+        // Salt
+        buffer.put(mCompleteDataElementsBytes.get(0));
+
+        // Identity
+        buffer.put(mCompleteDataElementsBytes.get(1));
+
+        List<Byte> rawDataBytes = new ArrayList<>();
+        // Data Elements (Already includes salt and identity)
+        for (int i = 2; i < mCompleteDataElementsBytes.size(); i++) {
+            byte[] dataElementBytes = mCompleteDataElementsBytes.get(i);
+            for (Byte b : dataElementBytes) {
+                rawDataBytes.add(b);
+            }
+        }
+
+        byte[] dataElements = new byte[rawDataBytes.size()];
+        for (int i = 0; i < rawDataBytes.size(); i++) {
+            dataElements[i] = rawDataBytes.get(i);
+        }
+
+        buffer.put(
+                getCryptor(/* encrypt= */ true).encrypt(dataElements, getSalt(), mAuthenticityKey));
+
+        buffer.put(mHmacTag);
+
+        return buffer.array();
+    }
+
+    /** Deserialize from bytes into an {@link ExtendedAdvertisement} object.
+     * {@code null} when there is something when parsing.
+     */
+    @Nullable
+    public static ExtendedAdvertisement fromBytes(byte[] bytes, PublicCredential publicCredential) {
+        @BroadcastRequest.BroadcastVersion
+        int version = ExtendedAdvertisementUtils.getVersion(bytes);
+        if (version != PresenceBroadcastRequest.PRESENCE_VERSION_V1) {
+            Log.v(TAG, "ExtendedAdvertisement is used in V1 only and version is " + version);
+            return null;
+        }
+
+        byte[] authenticityKey = publicCredential.getAuthenticityKey();
+
+        int index = HEADER_LENGTH;
+        // Salt
+        byte[] saltHeaderArray = ExtendedAdvertisementUtils.getDataElementHeader(bytes, index);
+        DataElementHeader saltHeader = DataElementHeader.fromBytes(version, saltHeaderArray);
+        if (saltHeader == null || saltHeader.getDataType() != DataElement.DataType.SALT) {
+            Log.v(TAG, "First data element has to be salt.");
+            return null;
+        }
+        index += saltHeaderArray.length;
+        byte[] salt = new byte[saltHeader.getDataLength()];
+        for (int i = 0; i < saltHeader.getDataLength(); i++) {
+            salt[i] = bytes[index++];
+        }
+
+        // Identity
+        byte[] identityHeaderArray = ExtendedAdvertisementUtils.getDataElementHeader(bytes, index);
+        DataElementHeader identityHeader =
+                DataElementHeader.fromBytes(version, identityHeaderArray);
+        if (identityHeader == null) {
+            Log.v(TAG, "The second element has to be identity.");
+            return null;
+        }
+        index += identityHeaderArray.length;
+        @PresenceCredential.IdentityType int identityType =
+                toPresenceCredentialIdentityType(identityHeader.getDataType());
+        if (identityType == PresenceCredential.IDENTITY_TYPE_UNKNOWN) {
+            Log.v(TAG, "The identity type is unknown.");
+            return null;
+        }
+        byte[] encryptedIdentity = new byte[identityHeader.getDataLength()];
+        for (int i = 0; i < identityHeader.getDataLength(); i++) {
+            encryptedIdentity[i] = bytes[index++];
+        }
+        byte[] identity =
+                CryptorImpIdentityV1
+                        .getInstance().decrypt(encryptedIdentity, salt, authenticityKey);
+
+        Cryptor cryptor = getCryptor(/* encrypt= */ true);
+        byte[] encryptedDataElements =
+                new byte[bytes.length - index - cryptor.getSignatureLength()];
+        // Decrypt other data elements
+        System.arraycopy(bytes, index, encryptedDataElements, 0, encryptedDataElements.length);
+        byte[] decryptedDataElements =
+                cryptor.decrypt(encryptedDataElements, salt, authenticityKey);
+        if (decryptedDataElements == null) {
+            return null;
+        }
+
+        // Verify the computed HMAC tag is equal to HMAC tag in advertisement
+        if (cryptor.getSignatureLength() > 0) {
+            byte[] expectedHmacTag = new byte[cryptor.getSignatureLength()];
+            System.arraycopy(
+                    bytes, bytes.length - cryptor.getSignatureLength(),
+                    expectedHmacTag, 0, cryptor.getSignatureLength());
+            if (!cryptor.verify(decryptedDataElements, authenticityKey, expectedHmacTag)) {
+                Log.e(TAG, "HMAC tags not match.");
+                return null;
+            }
+        }
+
+        int dataElementArrayIndex = 0;
+        // Other Data Elements
+        List<Integer> actions = new ArrayList<>();
+        List<DataElement> dataElements = new ArrayList<>();
+        while (dataElementArrayIndex < decryptedDataElements.length) {
+            byte[] deHeaderArray = ExtendedAdvertisementUtils
+                    .getDataElementHeader(decryptedDataElements, dataElementArrayIndex);
+            DataElementHeader deHeader = DataElementHeader.fromBytes(version, deHeaderArray);
+            dataElementArrayIndex += deHeaderArray.length;
+
+            @DataElement.DataType int type = Objects.requireNonNull(deHeader).getDataType();
+            if (type == DataElement.DataType.ACTION) {
+                if (deHeader.getDataLength() != 1) {
+                    Log.v(TAG, "Action id should only 1 byte.");
+                    return null;
+                }
+                actions.add((int) decryptedDataElements[dataElementArrayIndex++]);
+            } else {
+                if (isSaltOrIdentity(type)) {
+                    Log.v(TAG, "Type " + type + " is duplicated. There should be only one salt"
+                            + " and one identity in the advertisement.");
+                    return null;
+                }
+                byte[] deData = new byte[deHeader.getDataLength()];
+                for (int i = 0; i < deHeader.getDataLength(); i++) {
+                    deData[i] = decryptedDataElements[dataElementArrayIndex++];
+                }
+                dataElements.add(new DataElement(type, deData));
+            }
+        }
+
+        return new ExtendedAdvertisement(identityType, identity, salt, authenticityKey, actions,
+                dataElements);
+    }
+
+    /** Returns the {@link DataElement}s in the advertisement. */
+    public List<DataElement> getDataElements() {
+        return new ArrayList<>(mDataElements);
+    }
+
+    /** Returns the {@link DataElement}s in the advertisement according to the key. */
+    public List<DataElement> getDataElements(@DataElement.DataType int key) {
+        List<DataElement> res = new ArrayList<>();
+        for (DataElement dataElement : mDataElements) {
+            if (key == dataElement.getKey()) {
+                res.add(dataElement);
+            }
+        }
+        return res;
+    }
+
+    @Override
+    public String toString() {
+        return String.format(
+                "ExtendedAdvertisement:"
+                        + "<VERSION: %s, length: %s, dataElementCount: %s, identityType: %s,"
+                        + " identity: %s, salt: %s, actions: %s>",
+                getVersion(),
+                getLength(),
+                getDataElements().size(),
+                getIdentityType(),
+                Arrays.toString(getIdentity()),
+                Arrays.toString(getSalt()),
+                getActions());
+    }
+
+    ExtendedAdvertisement(
+            @PresenceCredential.IdentityType int identityType,
+            byte[] identity,
+            byte[] salt,
+            byte[] authenticityKey,
+            List<Integer> actions,
+            List<DataElement> dataElements) {
+        this.mVersion = BroadcastRequest.PRESENCE_VERSION_V1;
+        this.mIdentityType = identityType;
+        this.mIdentity = identity;
+        this.mSalt = salt;
+        this.mAuthenticityKey = authenticityKey;
+        this.mActions = actions;
+        this.mDataElements = dataElements;
+        this.mCompleteDataElementsBytes = new ArrayList<>();
+
+        int length = HEADER_LENGTH; // header
+
+        // Salt
+        DataElement saltElement = new DataElement(DataElement.DataType.SALT, salt);
+        byte[] saltByteArray = ExtendedAdvertisementUtils.convertDataElementToBytes(saltElement);
+        mCompleteDataElementsBytes.add(saltByteArray);
+        length += saltByteArray.length;
+
+        // Identity
+        byte[] encryptedIdentity =
+                CryptorImpIdentityV1.getInstance().encrypt(identity, salt, authenticityKey);
+        DataElement identityElement = new DataElement(toDataType(identityType), encryptedIdentity);
+        byte[] identityByteArray =
+                ExtendedAdvertisementUtils.convertDataElementToBytes(identityElement);
+        mCompleteDataElementsBytes.add(identityByteArray);
+        length += identityByteArray.length;
+
+        List<Byte> dataElementBytes = new ArrayList<>();
+        // Intents
+        for (int action : mActions) {
+            DataElement actionElement = new DataElement(DataElement.DataType.ACTION,
+                    new byte[] {(byte) action});
+            byte[] intentByteArray =
+                    ExtendedAdvertisementUtils.convertDataElementToBytes(actionElement);
+            mCompleteDataElementsBytes.add(intentByteArray);
+            for (Byte b : intentByteArray) {
+                dataElementBytes.add(b);
+            }
+        }
+
+        // Data Elements (Extended properties)
+        for (DataElement dataElement : mDataElements) {
+            byte[] deByteArray = ExtendedAdvertisementUtils.convertDataElementToBytes(dataElement);
+            mCompleteDataElementsBytes.add(deByteArray);
+            for (Byte b : deByteArray) {
+                dataElementBytes.add(b);
+            }
+        }
+
+        byte[] data = new byte[dataElementBytes.size()];
+        for (int i = 0; i < dataElementBytes.size(); i++) {
+            data[i] = dataElementBytes.get(i);
+        }
+        Cryptor cryptor = getCryptor(/* encrypt= */ true);
+        byte[] encryptedDeBytes = cryptor.encrypt(data, salt, authenticityKey);
+
+        length += encryptedDeBytes.length;
+
+        // Signature
+        byte[] hmacTag = Objects.requireNonNull(cryptor.sign(data, authenticityKey));
+        mHmacTag = hmacTag;
+        length += hmacTag.length;
+
+        this.mLength = length;
+    }
+
+    @PresenceCredential.IdentityType
+    private static int toPresenceCredentialIdentityType(@DataElement.DataType int type) {
+        switch (type) {
+            case DataElement.DataType.PRIVATE_IDENTITY:
+                return PresenceCredential.IDENTITY_TYPE_PRIVATE;
+            case DataElement.DataType.PROVISIONED_IDENTITY:
+                return PresenceCredential.IDENTITY_TYPE_PROVISIONED;
+            case DataElement.DataType.TRUSTED_IDENTITY:
+                return PresenceCredential.IDENTITY_TYPE_TRUSTED;
+            case DataElement.DataType.PUBLIC_IDENTITY:
+            default:
+                return PresenceCredential.IDENTITY_TYPE_UNKNOWN;
+        }
+    }
+
+    @DataElement.DataType
+    private static int toDataType(@PresenceCredential.IdentityType int identityType) {
+        switch (identityType) {
+            case PresenceCredential.IDENTITY_TYPE_PRIVATE:
+                return DataElement.DataType.PRIVATE_IDENTITY;
+            case PresenceCredential.IDENTITY_TYPE_PROVISIONED:
+                return DataElement.DataType.PROVISIONED_IDENTITY;
+            case PresenceCredential.IDENTITY_TYPE_TRUSTED:
+                return DataElement.DataType.TRUSTED_IDENTITY;
+            case PresenceCredential.IDENTITY_TYPE_UNKNOWN:
+            default:
+                return DataElement.DataType.PUBLIC_IDENTITY;
+        }
+    }
+
+    /**
+     * Returns {@code true} if the given {@link DataElement.DataType} is salt, or one of the
+     * identities. Identities should be able to convert to {@link PresenceCredential.IdentityType}s.
+     */
+    private static boolean isSaltOrIdentity(@DataElement.DataType int type) {
+        return type == DataElement.DataType.SALT || type == DataElement.DataType.PRIVATE_IDENTITY
+                || type == DataElement.DataType.TRUSTED_IDENTITY
+                || type == DataElement.DataType.PROVISIONED_IDENTITY
+                || type == DataElement.DataType.PUBLIC_IDENTITY;
+    }
+
+    private static Cryptor getCryptor(boolean encrypt) {
+        if (encrypt) {
+            Log.d(TAG, "get V1 Cryptor");
+            return CryptorImpV1.getInstance();
+        }
+        Log.d(TAG, "get fake Cryptor");
+        return CryptorImpFake.getInstance();
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/presence/ExtendedAdvertisementUtils.java b/nearby/service/java/com/android/server/nearby/presence/ExtendedAdvertisementUtils.java
new file mode 100644
index 0000000..06d0f2b
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/presence/ExtendedAdvertisementUtils.java
@@ -0,0 +1,100 @@
+/*
+ * 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.nearby.presence;
+
+import static com.android.server.nearby.presence.ExtendedAdvertisement.HEADER_LENGTH;
+
+import android.annotation.SuppressLint;
+import android.nearby.BroadcastRequest;
+import android.nearby.DataElement;
+
+import com.android.internal.util.Preconditions;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Provides serialization and deserialization util methods for {@link ExtendedAdvertisement}.
+ */
+public final class ExtendedAdvertisementUtils {
+
+    // Advertisement header related static fields.
+    private static final int VERSION_MASK = 0b11100000;
+    private static final int VERSION_MASK_AFTER_SHIT = 0b00000111;
+    private static final int HEADER_INDEX = 0;
+    private static final int HEADER_VERSION_OFFSET = 5;
+
+    /**
+     * Constructs the header of a {@link ExtendedAdvertisement}.
+     * 3 bit version, and 5 bit reserved for future use (RFU).
+     */
+    public static byte constructHeader(@BroadcastRequest.BroadcastVersion int version) {
+        return (byte) ((version << 5) & VERSION_MASK);
+    }
+
+    /** Returns the {@link BroadcastRequest.BroadcastVersion} from the advertisement
+     * in bytes format. */
+    public static int getVersion(byte[] advertisement) {
+        if (advertisement.length < HEADER_LENGTH) {
+            throw new IllegalArgumentException("Advertisement must contain header");
+        }
+        return ((advertisement[HEADER_INDEX] & VERSION_MASK) >> HEADER_VERSION_OFFSET)
+                & VERSION_MASK_AFTER_SHIT;
+    }
+
+    /** Returns the {@link DataElementHeader} from the advertisement in bytes format. */
+    public static byte[] getDataElementHeader(byte[] advertisement, int startIndex) {
+        Preconditions.checkArgument(startIndex < advertisement.length,
+                "Advertisement has no longer data left.");
+        List<Byte> headerBytes = new ArrayList<>();
+        while (startIndex < advertisement.length) {
+            byte current = advertisement[startIndex];
+            headerBytes.add(current);
+            if (!DataElementHeader.isExtending(current)) {
+                int size = headerBytes.size();
+                byte[] res = new byte[size];
+                for (int i = 0; i < size; i++) {
+                    res[i] = headerBytes.get(i);
+                }
+                return res;
+            }
+            startIndex++;
+        }
+        throw new IllegalArgumentException("There is no end of the DataElement header.");
+    }
+
+    /**
+     * Constructs {@link DataElement}, including header(s) and actual data element data.
+     *
+     * Suppresses warning because {@link DataElement} checks isValidType in constructor.
+     */
+    @SuppressLint("WrongConstant")
+    public static byte[] convertDataElementToBytes(DataElement dataElement) {
+        @DataElement.DataType int type = dataElement.getKey();
+        byte[] data = dataElement.getValue();
+        DataElementHeader header = new DataElementHeader(BroadcastRequest.PRESENCE_VERSION_V1,
+                type, data.length);
+        byte[] headerByteArray = header.toBytes();
+
+        byte[] res = new byte[headerByteArray.length + data.length];
+        System.arraycopy(headerByteArray, 0, res, 0, headerByteArray.length);
+        System.arraycopy(data, 0, res, headerByteArray.length, data.length);
+        return res;
+    }
+
+    private ExtendedAdvertisementUtils() {}
+}
diff --git a/nearby/service/java/com/android/server/nearby/presence/FastAdvertisement.java b/nearby/service/java/com/android/server/nearby/presence/FastAdvertisement.java
index e4df673..ae53ada 100644
--- a/nearby/service/java/com/android/server/nearby/presence/FastAdvertisement.java
+++ b/nearby/service/java/com/android/server/nearby/presence/FastAdvertisement.java
@@ -24,7 +24,6 @@
 import com.android.internal.util.Preconditions;
 
 import java.nio.ByteBuffer;
-import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
 
@@ -42,7 +41,7 @@
 // The header contains:
 // version (3 bits) | provision_mode_flag (1 bit) | identity_type (3 bits) |
 // extended_advertisement_mode (1 bit)
-public class FastAdvertisement {
+public class FastAdvertisement extends Advertisement {
 
     private static final int FAST_ADVERTISEMENT_MAX_LENGTH = 24;
 
@@ -85,7 +84,8 @@
                 (byte) request.getTxPower());
     }
 
-    /** Serialize an {@link FastAdvertisement} object into bytes. */
+    /** Serialize a {@link FastAdvertisement} object into bytes. */
+    @Override
     public byte[] toBytes() {
         ByteBuffer buffer = ByteBuffer.allocate(getLength());
 
@@ -100,18 +100,8 @@
         return buffer.array();
     }
 
-    private final int mLength;
-
     private final int mLtvFieldCount;
 
-    @PresenceCredential.IdentityType private final int mIdentityType;
-
-    private final byte[] mIdentity;
-
-    private final byte[] mSalt;
-
-    private final List<Integer> mActions;
-
     @Nullable
     private final Byte mTxPower;
 
@@ -121,6 +111,7 @@
             byte[] salt,
             List<Integer> actions,
             @Nullable Byte txPower) {
+        this.mVersion = BroadcastRequest.PRESENCE_VERSION_V0;
         this.mIdentityType = identityType;
         this.mIdentity = identity;
         this.mSalt = salt;
@@ -143,44 +134,12 @@
                 "FastAdvertisement exceeds maximum length");
     }
 
-    /** Returns the version in the advertisement. */
-    @BroadcastRequest.BroadcastVersion
-    public int getVersion() {
-        return BroadcastRequest.PRESENCE_VERSION_V0;
-    }
-
-    /** Returns the identity type in the advertisement. */
-    @PresenceCredential.IdentityType
-    public int getIdentityType() {
-        return mIdentityType;
-    }
-
-    /** Returns the identity bytes in the advertisement. */
-    public byte[] getIdentity() {
-        return mIdentity.clone();
-    }
-
-    /** Returns the salt of the advertisement. */
-    public byte[] getSalt() {
-        return mSalt.clone();
-    }
-
-    /** Returns the actions in the advertisement. */
-    public List<Integer> getActions() {
-        return new ArrayList<>(mActions);
-    }
-
     /** Returns the adjusted TX Power in the advertisement. Null if not available. */
     @Nullable
     public Byte getTxPower() {
         return mTxPower;
     }
 
-    /** Returns the length of the advertisement. */
-    public int getLength() {
-        return mLength;
-    }
-
     /** Returns the count of LTV fields in the advertisement. */
     public int getLtvFieldCount() {
         return mLtvFieldCount;
diff --git a/nearby/service/java/com/android/server/nearby/presence/PresenceDiscoveryResult.java b/nearby/service/java/com/android/server/nearby/presence/PresenceDiscoveryResult.java
index d1c72ae..5a76d96 100644
--- a/nearby/service/java/com/android/server/nearby/presence/PresenceDiscoveryResult.java
+++ b/nearby/service/java/com/android/server/nearby/presence/PresenceDiscoveryResult.java
@@ -16,31 +16,55 @@
 
 package com.android.server.nearby.presence;
 
-import android.nearby.NearbyDevice;
+import static com.android.server.nearby.NearbyService.TAG;
+
+import android.annotation.NonNull;
+import android.nearby.DataElement;
 import android.nearby.NearbyDeviceParcelable;
 import android.nearby.PresenceDevice;
 import android.nearby.PresenceScanFilter;
 import android.nearby.PublicCredential;
+import android.util.ArraySet;
+import android.util.Log;
+
+import com.android.internal.annotations.VisibleForTesting;
 
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.List;
+import java.util.Set;
 
 /** Represents a Presence discovery result. */
 public class PresenceDiscoveryResult {
 
     /** Creates a {@link PresenceDiscoveryResult} from the scan data. */
     public static PresenceDiscoveryResult fromDevice(NearbyDeviceParcelable device) {
+        PresenceDevice presenceDevice = device.getPresenceDevice();
+        if (presenceDevice != null) {
+            return new PresenceDiscoveryResult.Builder()
+                    .setTxPower(device.getTxPower())
+                    .setRssi(device.getRssi())
+                    .setSalt(presenceDevice.getSalt())
+                    .setPublicCredential(device.getPublicCredential())
+                    .addExtendedProperties(presenceDevice.getExtendedProperties())
+                    .setEncryptedIdentityTag(device.getEncryptionKeyTag())
+                    .build();
+        }
         byte[] salt = device.getSalt();
         if (salt == null) {
             salt = new byte[0];
         }
-        return new PresenceDiscoveryResult.Builder()
-                .setTxPower(device.getTxPower())
+
+        PresenceDiscoveryResult.Builder builder = new PresenceDiscoveryResult.Builder();
+        builder.setTxPower(device.getTxPower())
                 .setRssi(device.getRssi())
                 .setSalt(salt)
                 .addPresenceAction(device.getAction())
-                .setPublicCredential(device.getPublicCredential())
-                .build();
+                .setPublicCredential(device.getPublicCredential());
+        if (device.getPresenceDevice() != null) {
+            builder.addExtendedProperties(device.getPresenceDevice().getExtendedProperties());
+        }
+        return builder.build();
     }
 
     private final int mTxPower;
@@ -48,25 +72,35 @@
     private final byte[] mSalt;
     private final List<Integer> mPresenceActions;
     private final PublicCredential mPublicCredential;
+    private final List<DataElement> mExtendedProperties;
+    private final byte[] mEncryptedIdentityTag;
 
     private PresenceDiscoveryResult(
             int txPower,
             int rssi,
             byte[] salt,
             List<Integer> presenceActions,
-            PublicCredential publicCredential) {
+            PublicCredential publicCredential,
+            List<DataElement> extendedProperties,
+            byte[] encryptedIdentityTag) {
         mTxPower = txPower;
         mRssi = rssi;
         mSalt = salt;
         mPresenceActions = presenceActions;
         mPublicCredential = publicCredential;
+        mExtendedProperties = extendedProperties;
+        mEncryptedIdentityTag = encryptedIdentityTag;
     }
 
     /** Returns whether the discovery result matches the scan filter. */
     public boolean matches(PresenceScanFilter scanFilter) {
+        if (accountKeyMatches(scanFilter.getExtendedProperties())) {
+            return true;
+        }
+
         return pathLossMatches(scanFilter.getMaxPathLoss())
                 && actionMatches(scanFilter.getPresenceActions())
-                && credentialMatches(scanFilter.getCredentials());
+                && identityMatches(scanFilter.getCredentials());
     }
 
     private boolean pathLossMatches(int maxPathLoss) {
@@ -80,21 +114,47 @@
         return filterActions.stream().anyMatch(mPresenceActions::contains);
     }
 
-    private boolean credentialMatches(List<PublicCredential> credentials) {
-        return credentials.contains(mPublicCredential);
+    @VisibleForTesting
+    boolean accountKeyMatches(List<DataElement> extendedProperties) {
+        Set<byte[]> accountKeys = new ArraySet<>();
+        for (DataElement requestedDe : mExtendedProperties) {
+            if (requestedDe.getKey() != DataElement.DataType.ACCOUNT_KEY_DATA) {
+                continue;
+            }
+            accountKeys.add(requestedDe.getValue());
+        }
+        for (DataElement scannedDe : extendedProperties) {
+            if (scannedDe.getKey() != DataElement.DataType.ACCOUNT_KEY_DATA) {
+                continue;
+            }
+            // If one account key matches, then returns true.
+            for (byte[] key : accountKeys) {
+                if (Arrays.equals(key, scannedDe.getValue())) {
+                    return true;
+                }
+            }
+        }
+
+        return false;
     }
 
-    /** Converts a presence device from the discovery result. */
-    public PresenceDevice toPresenceDevice() {
-        return new PresenceDevice.Builder(
-                // Use the public credential hash as the device Id.
-                String.valueOf(mPublicCredential.hashCode()),
-                mSalt,
-                mPublicCredential.getSecretId(),
-                mPublicCredential.getEncryptedMetadata())
-                .setRssi(mRssi)
-                .addMedium(NearbyDevice.Medium.BLE)
-                .build();
+    @VisibleForTesting
+    /** Gets presence {@link DataElement}s of the discovery result. */
+    public List<DataElement> getExtendedProperties() {
+        return mExtendedProperties;
+    }
+
+    private boolean identityMatches(List<PublicCredential> publicCredentials) {
+        if (mEncryptedIdentityTag.length == 0) {
+            return true;
+        }
+        for (PublicCredential publicCredential : publicCredentials) {
+            if (Arrays.equals(
+                    mEncryptedIdentityTag, publicCredential.getEncryptedMetadataKeyTag())) {
+                return true;
+            }
+        }
+        return false;
     }
 
     /** Builder for {@link PresenceDiscoveryResult}. */
@@ -105,9 +165,12 @@
 
         private PublicCredential mPublicCredential;
         private final List<Integer> mPresenceActions;
+        private final List<DataElement> mExtendedProperties;
+        private byte[] mEncryptedIdentityTag = new byte[0];
 
         public Builder() {
             mPresenceActions = new ArrayList<>();
+            mExtendedProperties = new ArrayList<>();
         }
 
         /** Sets the calibrated tx power for the discovery result. */
@@ -130,7 +193,18 @@
 
         /** Sets the public credential for the discovery result. */
         public Builder setPublicCredential(PublicCredential publicCredential) {
-            mPublicCredential = publicCredential;
+            if (publicCredential != null) {
+                mPublicCredential = publicCredential;
+            }
+            return this;
+        }
+
+        /** Sets the encrypted identity tag for the discovery result. Usually it is passed from
+         * {@link NearbyDeviceParcelable} and the tag is calculated with authenticity key when
+         * receiving an advertisement.
+         */
+        public Builder setEncryptedIdentityTag(byte[] encryptedIdentityTag) {
+            mEncryptedIdentityTag = encryptedIdentityTag;
             return this;
         }
 
@@ -140,10 +214,34 @@
             return this;
         }
 
+        /** Adds presence {@link DataElement}s of the discovery result. */
+        public Builder addExtendedProperties(DataElement dataElement) {
+            if (dataElement.getKey() == DataElement.DataType.ACTION) {
+                byte[] value = dataElement.getValue();
+                if (value.length == 1) {
+                    addPresenceAction(Byte.toUnsignedInt(value[0]));
+                } else {
+                    Log.e(TAG, "invalid action data element");
+                }
+            } else {
+                mExtendedProperties.add(dataElement);
+            }
+            return this;
+        }
+
+        /** Adds presence {@link DataElement}s of the discovery result. */
+        public Builder addExtendedProperties(@NonNull List<DataElement> dataElements) {
+            for (DataElement dataElement : dataElements) {
+                addExtendedProperties(dataElement);
+            }
+            return this;
+        }
+
         /** Builds a {@link PresenceDiscoveryResult}. */
         public PresenceDiscoveryResult build() {
             return new PresenceDiscoveryResult(
-                    mTxPower, mRssi, mSalt, mPresenceActions, mPublicCredential);
+                    mTxPower, mRssi, mSalt, mPresenceActions,
+                    mPublicCredential, mExtendedProperties, mEncryptedIdentityTag);
         }
     }
 }
diff --git a/nearby/service/java/com/android/server/nearby/presence/PresenceManager.java b/nearby/service/java/com/android/server/nearby/presence/PresenceManager.java
new file mode 100644
index 0000000..0a51068
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/presence/PresenceManager.java
@@ -0,0 +1,141 @@
+/*
+ * 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.nearby.presence;
+
+import static com.android.server.nearby.NearbyService.TAG;
+
+import android.annotation.Nullable;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.nearby.DataElement;
+import android.nearby.NearbyDevice;
+import android.nearby.NearbyManager;
+import android.nearby.PresenceDevice;
+import android.nearby.PresenceScanFilter;
+import android.nearby.PublicCredential;
+import android.nearby.ScanCallback;
+import android.nearby.ScanRequest;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+
+import com.android.internal.annotations.VisibleForTesting;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Locale;
+import java.util.concurrent.Executors;
+
+/** PresenceManager is the class initiated in nearby service to handle presence related work. */
+public class PresenceManager {
+
+    final Context mContext;
+    private final IntentFilter mIntentFilter;
+
+    @VisibleForTesting
+    final ScanCallback mScanCallback =
+            new ScanCallback() {
+                @Override
+                public void onDiscovered(@NonNull NearbyDevice device) {
+                    Log.i(TAG, "[PresenceManager] discovered Device.");
+                    PresenceDevice presenceDevice = (PresenceDevice) device;
+                    List<DataElement> dataElements = presenceDevice.getExtendedProperties();
+                    for (DataElement dataElement : dataElements) {
+                        Log.i(TAG, "[PresenceManager] Data Element key "
+                                + dataElement.getKey());
+                        Log.i(TAG, "[PresenceManager] Data Element value "
+                                + Arrays.toString(dataElement.getValue()));
+                    }
+                }
+
+                @Override
+                public void onUpdated(@NonNull NearbyDevice device) {}
+
+                @Override
+                public void onLost(@NonNull NearbyDevice device) {}
+
+                @Override
+                public void onError(int errorCode) {
+                    Log.w(TAG, "[PresenceManager] Scan error is " + errorCode);
+                }
+            };
+
+    private final BroadcastReceiver mScreenBroadcastReceiver =
+            new BroadcastReceiver() {
+                @Override
+                public void onReceive(Context context, Intent intent) {
+                    NearbyManager manager = getNearbyManager();
+                    if (manager == null) {
+                        Log.e(TAG, "Nearby Manager is null");
+                        return;
+                    }
+                    if (intent.getAction().equals(Intent.ACTION_SCREEN_ON)) {
+                        Log.d(TAG, "PresenceManager Start scan.");
+                        PublicCredential publicCredential =
+                                new PublicCredential.Builder(new byte[]{1}, new byte[]{1},
+                                        new byte[]{1}, new byte[]{1}, new byte[]{1}).build();
+                        PresenceScanFilter presenceScanFilter =
+                                new PresenceScanFilter.Builder()
+                                        .setMaxPathLoss(3)
+                                        .addCredential(publicCredential)
+                                        .addPresenceAction(1)
+                                        .addExtendedProperty(new DataElement(
+                                                DataElement.DataType.ACCOUNT_KEY_DATA,
+                                                new byte[16]))
+                                        .build();
+                        ScanRequest scanRequest =
+                                new ScanRequest.Builder()
+                                        .setScanType(ScanRequest.SCAN_TYPE_NEARBY_PRESENCE)
+                                        .addScanFilter(presenceScanFilter)
+                                        .build();
+                        Log.d(
+                                TAG,
+                                String.format(
+                                        Locale.getDefault(),
+                                        "[PresenceManager] Start Presence scan with request: %s",
+                                        scanRequest.toString()));
+                        manager.startScan(
+                                scanRequest, Executors.newSingleThreadExecutor(), mScanCallback);
+                    } else if (intent.getAction().equals(Intent.ACTION_SCREEN_OFF)) {
+                        Log.d(TAG, "PresenceManager Stop scan.");
+                        manager.stopScan(mScanCallback);
+                    }
+                }
+            };
+
+    public PresenceManager(Context context) {
+        mContext = context;
+        mIntentFilter = new IntentFilter();
+    }
+
+    /** Null when the Nearby Service is not available. */
+    @Nullable
+    private NearbyManager getNearbyManager() {
+        return (NearbyManager)
+                mContext.getApplicationContext()
+                        .getSystemService(Context.NEARBY_SERVICE);
+    }
+
+    /** Function called when nearby service start. */
+    public void initiate() {
+        mIntentFilter.addAction(Intent.ACTION_SCREEN_ON);
+        mIntentFilter.addAction(Intent.ACTION_SCREEN_OFF);
+        mContext.registerReceiver(mScreenBroadcastReceiver, mIntentFilter);
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/provider/AbstractDiscoveryProvider.java b/nearby/service/java/com/android/server/nearby/provider/AbstractDiscoveryProvider.java
index f136695..3de6ff0 100644
--- a/nearby/service/java/com/android/server/nearby/provider/AbstractDiscoveryProvider.java
+++ b/nearby/service/java/com/android/server/nearby/provider/AbstractDiscoveryProvider.java
@@ -20,6 +20,7 @@
 
 import android.content.Context;
 import android.nearby.NearbyDeviceParcelable;
+import android.nearby.ScanCallback;
 import android.nearby.ScanFilter;
 import android.nearby.ScanRequest;
 import android.util.Log;
@@ -40,15 +41,6 @@
     protected final DiscoveryProviderController mController;
     protected final Executor mExecutor;
     protected Listener mListener;
-    protected List<ScanFilter> mScanFilters;
-
-    /** Interface for listening to discovery providers. */
-    public interface Listener {
-        /**
-         * Called when a provider has a new nearby device available. May be invoked from any thread.
-         */
-        void onNearbyDeviceDiscovered(NearbyDeviceParcelable nearbyDevice);
-    }
 
     protected AbstractDiscoveryProvider(Context context, Executor executor) {
         mContext = context;
@@ -77,14 +69,33 @@
     protected void invalidateScanMode() {}
 
     /**
+     * Callback invoked to inform the provider of new provider scan filters which replaces any prior
+     * provider filters. Always invoked on the provider executor.
+     */
+    protected void onSetScanFilters(List<ScanFilter> filters) {}
+
+    /**
      * Retrieves the controller for this discovery provider. Should never be invoked by subclasses,
      * as a discovery provider should not be controlling itself. Using this method from subclasses
      * could also result in deadlock.
      */
-    protected DiscoveryProviderController getController() {
+    public DiscoveryProviderController getController() {
         return mController;
     }
 
+    /** Interface for listening to discovery providers. */
+    public interface Listener {
+        /**
+         * Called when a provider has a new nearby device available. May be invoked from any thread.
+         */
+        void onNearbyDeviceDiscovered(NearbyDeviceParcelable nearbyDevice);
+
+        /**
+         * Called when a provider found error from the scan.
+         */
+        void onError(@ScanCallback.ErrorCode int errorCode);
+    }
+
     private class Controller implements DiscoveryProviderController {
 
         private boolean mStarted = false;
@@ -120,6 +131,12 @@
             mExecutor.execute(AbstractDiscoveryProvider.this::onStop);
         }
 
+        @ScanRequest.ScanMode
+        @Override
+        public int getProviderScanMode() {
+            return mScanMode;
+        }
+
         @Override
         public void setProviderScanMode(@ScanRequest.ScanMode int scanMode) {
             if (mScanMode == scanMode) {
@@ -130,15 +147,9 @@
             mExecutor.execute(AbstractDiscoveryProvider.this::invalidateScanMode);
         }
 
-        @ScanRequest.ScanMode
-        @Override
-        public int getProviderScanMode() {
-            return mScanMode;
-        }
-
         @Override
         public void setProviderScanFilters(List<ScanFilter> filters) {
-            mScanFilters = filters;
+            mExecutor.execute(() -> onSetScanFilters(filters));
         }
     }
 }
diff --git a/nearby/service/java/com/android/server/nearby/provider/BleBroadcastProvider.java b/nearby/service/java/com/android/server/nearby/provider/BleBroadcastProvider.java
index 67392ad..6829fba 100644
--- a/nearby/service/java/com/android/server/nearby/provider/BleBroadcastProvider.java
+++ b/nearby/service/java/com/android/server/nearby/provider/BleBroadcastProvider.java
@@ -16,17 +16,24 @@
 
 package com.android.server.nearby.provider;
 
+import static com.android.server.nearby.NearbyService.TAG;
+import static com.android.server.nearby.presence.PresenceConstants.PRESENCE_UUID;
+
 import android.bluetooth.BluetoothAdapter;
 import android.bluetooth.le.AdvertiseCallback;
 import android.bluetooth.le.AdvertiseData;
 import android.bluetooth.le.AdvertiseSettings;
+import android.bluetooth.le.AdvertisingSet;
+import android.bluetooth.le.AdvertisingSetCallback;
+import android.bluetooth.le.AdvertisingSetParameters;
 import android.bluetooth.le.BluetoothLeAdvertiser;
 import android.nearby.BroadcastCallback;
-import android.os.ParcelUuid;
+import android.nearby.BroadcastRequest;
+import android.util.Log;
 
+import com.android.internal.annotations.VisibleForTesting;
 import com.android.server.nearby.injector.Injector;
 
-import java.util.UUID;
 import java.util.concurrent.Executor;
 
 /**
@@ -37,7 +44,7 @@
     /**
      * Listener for Broadcast status changes.
      */
-    interface BroadcastListener {
+    public interface BroadcastListener {
         void onStatusChanged(int status);
     }
 
@@ -46,13 +53,19 @@
 
     private BroadcastListener mBroadcastListener;
     private boolean mIsAdvertising;
-
-    BleBroadcastProvider(Injector injector, Executor executor) {
+    @VisibleForTesting
+    AdvertisingSetCallback mAdvertisingSetCallback;
+    public BleBroadcastProvider(Injector injector, Executor executor) {
         mInjector = injector;
         mExecutor = executor;
+        mAdvertisingSetCallback = getAdvertisingSetCallback();
     }
 
-    void start(byte[] advertisementPackets, BroadcastListener listener) {
+    /**
+     * Starts to broadcast with given bytes.
+     */
+    public void start(@BroadcastRequest.BroadcastVersion int version, byte[] advertisementPackets,
+            BroadcastListener listener) {
         if (mIsAdvertising) {
             stop();
         }
@@ -63,23 +76,35 @@
                     mInjector.getBluetoothAdapter().getBluetoothLeAdvertiser();
             if (bluetoothLeAdvertiser != null) {
                 advertiseStarted = true;
-                AdvertiseSettings settings =
-                        new AdvertiseSettings.Builder()
-                                .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_BALANCED)
-                                .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_MEDIUM)
-                                .setConnectable(true)
-                                .build();
-
-                // TODO(b/230538655) Use empty data until Presence V1 protocol is implemented.
-                ParcelUuid emptyParcelUuid = new ParcelUuid(new UUID(0L, 0L));
-                byte[] emptyAdvertisementPackets = new byte[0];
                 AdvertiseData advertiseData =
                         new AdvertiseData.Builder()
-                                .addServiceData(emptyParcelUuid, emptyAdvertisementPackets).build();
+                                .addServiceData(PRESENCE_UUID, advertisementPackets).build();
                 try {
                     mBroadcastListener = listener;
-                    bluetoothLeAdvertiser.startAdvertising(settings, advertiseData, this);
+                    switch (version) {
+                        case BroadcastRequest.PRESENCE_VERSION_V0:
+                            bluetoothLeAdvertiser.startAdvertising(getAdvertiseSettings(),
+                                    advertiseData, this);
+                            break;
+                        case BroadcastRequest.PRESENCE_VERSION_V1:
+                            if (adapter.isLeExtendedAdvertisingSupported()) {
+                                bluetoothLeAdvertiser.startAdvertisingSet(
+                                        getAdvertisingSetParameters(),
+                                        advertiseData,
+                                        null, null, null, mAdvertisingSetCallback);
+                            } else {
+                                Log.w(TAG, "Failed to start advertising set because the chipset"
+                                        + " does not supports LE Extended Advertising feature.");
+                                advertiseStarted = false;
+                            }
+                            break;
+                        default:
+                            Log.w(TAG, "Failed to start advertising set because the advertisement"
+                                    + " is wrong.");
+                            advertiseStarted = false;
+                    }
                 } catch (NullPointerException | IllegalStateException | SecurityException e) {
+                    Log.w(TAG, "Failed to start advertising.", e);
                     advertiseStarted = false;
                 }
             }
@@ -89,7 +114,10 @@
         }
     }
 
-    void stop() {
+    /**
+     * Stops current advertisement.
+     */
+    public void stop() {
         if (mIsAdvertising) {
             BluetoothAdapter adapter = mInjector.getBluetoothAdapter();
             if (adapter != null) {
@@ -97,6 +125,7 @@
                         mInjector.getBluetoothAdapter().getBluetoothLeAdvertiser();
                 if (bluetoothLeAdvertiser != null) {
                     bluetoothLeAdvertiser.stopAdvertising(this);
+                    bluetoothLeAdvertiser.stopAdvertisingSet(mAdvertisingSetCallback);
                 }
             }
             mBroadcastListener = null;
@@ -120,4 +149,41 @@
             mBroadcastListener.onStatusChanged(BroadcastCallback.STATUS_FAILURE);
         }
     }
+
+    private static AdvertiseSettings getAdvertiseSettings() {
+        return new AdvertiseSettings.Builder()
+                .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_BALANCED)
+                .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_MEDIUM)
+                .setConnectable(true)
+                .build();
+    }
+
+    private static AdvertisingSetParameters getAdvertisingSetParameters() {
+        return new AdvertisingSetParameters.Builder()
+                .setInterval(AdvertisingSetParameters.INTERVAL_MEDIUM)
+                .setTxPowerLevel(AdvertisingSetParameters.TX_POWER_MEDIUM)
+                .setIncludeTxPower(true)
+                .setConnectable(true)
+                .build();
+    }
+
+    private AdvertisingSetCallback getAdvertisingSetCallback() {
+        return new AdvertisingSetCallback() {
+            @Override
+            public void onAdvertisingSetStarted(AdvertisingSet advertisingSet,
+                    int txPower, int status) {
+                if (status == AdvertisingSetCallback.ADVERTISE_SUCCESS) {
+                    if (mBroadcastListener != null) {
+                        mBroadcastListener.onStatusChanged(BroadcastCallback.STATUS_OK);
+                    }
+                    mIsAdvertising = true;
+                } else {
+                    Log.e(TAG, "Starts advertising failed in status " + status);
+                    if (mBroadcastListener != null) {
+                        mBroadcastListener.onStatusChanged(BroadcastCallback.STATUS_FAILURE);
+                    }
+                }
+            }
+        };
+    }
 }
diff --git a/nearby/service/java/com/android/server/nearby/provider/BleDiscoveryProvider.java b/nearby/service/java/com/android/server/nearby/provider/BleDiscoveryProvider.java
index e2fbe77..355f7cf 100644
--- a/nearby/service/java/com/android/server/nearby/provider/BleDiscoveryProvider.java
+++ b/nearby/service/java/com/android/server/nearby/provider/BleDiscoveryProvider.java
@@ -16,6 +16,8 @@
 
 package com.android.server.nearby.provider;
 
+import static android.nearby.ScanCallback.ERROR_UNKNOWN;
+
 import static com.android.server.nearby.NearbyService.TAG;
 import static com.android.server.nearby.presence.PresenceConstants.PRESENCE_UUID;
 
@@ -28,18 +30,27 @@
 import android.bluetooth.le.ScanResult;
 import android.bluetooth.le.ScanSettings;
 import android.content.Context;
+import android.nearby.DataElement;
 import android.nearby.NearbyDevice;
 import android.nearby.NearbyDeviceParcelable;
+import android.nearby.PresenceDevice;
+import android.nearby.PresenceScanFilter;
+import android.nearby.PublicCredential;
 import android.nearby.ScanRequest;
 import android.os.ParcelUuid;
 import android.util.Log;
 
+import com.android.internal.annotations.GuardedBy;
 import com.android.server.nearby.injector.Injector;
+import com.android.server.nearby.presence.ExtendedAdvertisement;
+import com.android.server.nearby.util.ArrayUtils;
 import com.android.server.nearby.util.ForegroundThread;
+import com.android.server.nearby.util.encryption.CryptorImpIdentityV1;
 
 import com.google.common.annotations.VisibleForTesting;
 
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.Executor;
@@ -52,15 +63,32 @@
     // Don't block the thread as it may be used by other services.
     private static final Executor NEARBY_EXECUTOR = ForegroundThread.getExecutor();
     private final Injector mInjector;
+    private final Object mLock = new Object();
+    // Null when the filters are never set
+    @VisibleForTesting
+    @GuardedBy("mLock")
+    @Nullable
+    private List<android.nearby.ScanFilter> mScanFilters;
+    private android.bluetooth.le.ScanCallback mScanCallbackLegacy =
+            new android.bluetooth.le.ScanCallback() {
+                @Override
+                public void onScanResult(int callbackType, ScanResult scanResult) {
+                }
+                @Override
+                public void onScanFailed(int errorCode) {
+                }
+            };
     private android.bluetooth.le.ScanCallback mScanCallback =
             new android.bluetooth.le.ScanCallback() {
                 @Override
                 public void onScanResult(int callbackType, ScanResult scanResult) {
                     NearbyDeviceParcelable.Builder builder = new NearbyDeviceParcelable.Builder();
-                    builder.setMedium(NearbyDevice.Medium.BLE)
+                    String bleAddress = scanResult.getDevice().getAddress();
+                    builder.setDeviceId(bleAddress.hashCode())
+                            .setMedium(NearbyDevice.Medium.BLE)
                             .setRssi(scanResult.getRssi())
                             .setTxPower(scanResult.getTxPower())
-                            .setBluetoothAddress(scanResult.getDevice().getAddress());
+                            .setBluetoothAddress(bleAddress);
 
                     ScanRecord record = scanResult.getScanRecord();
                     if (record != null) {
@@ -72,7 +100,8 @@
                         if (serviceDataMap != null) {
                             byte[] presenceData = serviceDataMap.get(PRESENCE_UUID);
                             if (presenceData != null) {
-                                builder.setData(serviceDataMap.get(PRESENCE_UUID));
+                                setPresenceDevice(presenceData, builder, deviceName,
+                                        scanResult.getRssi());
                             }
                         }
                     }
@@ -81,7 +110,8 @@
 
                 @Override
                 public void onScanFailed(int errorCode) {
-                    Log.w(TAG, "BLE Scan failed with error code " + errorCode);
+                    Log.w(TAG, "BLE 5.0 Scan failed with error code " + errorCode);
+                    mExecutor.execute(() -> mListener.onError(ERROR_UNKNOWN));
                 }
             };
 
@@ -90,6 +120,29 @@
         mInjector = injector;
     }
 
+    private static PresenceDevice getPresenceDevice(ExtendedAdvertisement advertisement,
+            String deviceName, int rssi) {
+        // TODO(238458326): After implementing encryption, use real data.
+        byte[] secretIdBytes = new byte[0];
+        PresenceDevice.Builder builder =
+                new PresenceDevice.Builder(
+                        String.valueOf(advertisement.hashCode()),
+                        advertisement.getSalt(),
+                        secretIdBytes,
+                        advertisement.getIdentity())
+                        .addMedium(NearbyDevice.Medium.BLE)
+                        .setName(deviceName)
+                        .setRssi(rssi);
+        for (int i : advertisement.getActions()) {
+            builder.addExtendedProperty(new DataElement(DataElement.DataType.ACTION,
+                    new byte[]{(byte) i}));
+        }
+        for (DataElement dataElement : advertisement.getDataElements()) {
+            builder.addExtendedProperty(dataElement);
+        }
+        return builder.build();
+    }
+
     private static List<ScanFilter> getScanFilters() {
         List<ScanFilter> scanFilterList = new ArrayList<>();
         scanFilterList.add(
@@ -120,8 +173,9 @@
     @Override
     protected void onStart() {
         if (isBleAvailable()) {
-            Log.d(TAG, "BleDiscoveryProvider started.");
-            startScan(getScanFilters(), getScanSettings(), mScanCallback);
+            Log.d(TAG, "BleDiscoveryProvider started");
+            startScan(getScanFilters(), getScanSettings(/* legacy= */ false), mScanCallback);
+            startScan(getScanFilters(), getScanSettings(/* legacy= */ true), mScanCallbackLegacy);
             return;
         }
         Log.w(TAG, "Cannot start BleDiscoveryProvider because Ble is not available.");
@@ -138,6 +192,12 @@
         }
         Log.v(TAG, "Ble scan stopped.");
         bluetoothLeScanner.stopScan(mScanCallback);
+        bluetoothLeScanner.stopScan(mScanCallbackLegacy);
+        synchronized (mLock) {
+            if (mScanFilters != null) {
+                mScanFilters = null;
+            }
+        }
     }
 
     @Override
@@ -146,6 +206,20 @@
         onStart();
     }
 
+    @Override
+    protected void onSetScanFilters(List<android.nearby.ScanFilter> filters) {
+        synchronized (mLock) {
+            mScanFilters = filters == null ? null : List.copyOf(filters);
+        }
+    }
+
+    @VisibleForTesting
+    protected List<android.nearby.ScanFilter> getFiltersLocked() {
+        synchronized (mLock) {
+            return mScanFilters == null ? null : List.copyOf(mScanFilters);
+        }
+    }
+
     private void startScan(
             List<ScanFilter> scanFilters, ScanSettings scanSettings,
             android.bluetooth.le.ScanCallback scanCallback) {
@@ -169,7 +243,7 @@
         }
     }
 
-    private ScanSettings getScanSettings() {
+    private ScanSettings getScanSettings(boolean legacy) {
         int bleScanMode = ScanSettings.SCAN_MODE_LOW_POWER;
         switch (mController.getProviderScanMode()) {
             case ScanRequest.SCAN_MODE_LOW_LATENCY:
@@ -185,11 +259,45 @@
                 bleScanMode = ScanSettings.SCAN_MODE_OPPORTUNISTIC;
                 break;
         }
-        return new ScanSettings.Builder().setScanMode(bleScanMode).build();
+        return new ScanSettings.Builder().setScanMode(bleScanMode).setLegacy(legacy).build();
     }
 
     @VisibleForTesting
     ScanCallback getScanCallback() {
         return mScanCallback;
     }
+
+    private void setPresenceDevice(byte[] data, NearbyDeviceParcelable.Builder builder,
+            String deviceName, int rssi) {
+        synchronized (mLock) {
+            if (mScanFilters == null) {
+                return;
+            }
+            for (android.nearby.ScanFilter scanFilter : mScanFilters) {
+                if (scanFilter instanceof PresenceScanFilter) {
+                    // Iterate all possible authenticity key and identity combinations to decrypt
+                    // advertisement
+                    PresenceScanFilter presenceFilter = (PresenceScanFilter) scanFilter;
+                    for (PublicCredential credential : presenceFilter.getCredentials()) {
+                        ExtendedAdvertisement advertisement =
+                                ExtendedAdvertisement.fromBytes(data, credential);
+                        if (advertisement == null) {
+                            continue;
+                        }
+                        if (CryptorImpIdentityV1.getInstance().verify(
+                                advertisement.getIdentity(),
+                                credential.getEncryptedMetadataKeyTag())) {
+                            builder.setPresenceDevice(getPresenceDevice(advertisement, deviceName,
+                                    rssi));
+                            builder.setEncryptionKeyTag(credential.getEncryptedMetadataKeyTag());
+                            if (!ArrayUtils.isEmpty(credential.getSecretId())) {
+                                builder.setDeviceId(Arrays.hashCode(credential.getSecretId()));
+                            }
+                            return;
+                        }
+                    }
+                }
+            }
+        }
+    }
 }
diff --git a/nearby/service/java/com/android/server/nearby/provider/ChreCommunication.java b/nearby/service/java/com/android/server/nearby/provider/ChreCommunication.java
index 5077ffe..020c7b2 100644
--- a/nearby/service/java/com/android/server/nearby/provider/ChreCommunication.java
+++ b/nearby/service/java/com/android/server/nearby/provider/ChreCommunication.java
@@ -19,15 +19,18 @@
 import static com.android.server.nearby.NearbyService.TAG;
 
 import android.annotation.Nullable;
+import android.content.Context;
 import android.hardware.location.ContextHubClient;
 import android.hardware.location.ContextHubClientCallback;
 import android.hardware.location.ContextHubInfo;
+import android.hardware.location.ContextHubManager;
 import android.hardware.location.ContextHubTransaction;
 import android.hardware.location.NanoAppMessage;
 import android.hardware.location.NanoAppState;
 import android.util.Log;
 
-import com.android.server.nearby.injector.ContextHubManagerAdapter;
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.server.nearby.NearbyConfiguration;
 import com.android.server.nearby.injector.Injector;
 
 import com.google.common.base.Preconditions;
@@ -36,7 +39,9 @@
 import java.util.List;
 import java.util.Locale;
 import java.util.Set;
+import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.Executor;
+import java.util.concurrent.TimeUnit;
 
 /**
  * Responsible for setting up communication with the appropriate contexthub on the device and
@@ -44,6 +49,8 @@
  */
 public class ChreCommunication extends ContextHubClientCallback {
 
+    public static final int INVALID_NANO_APP_VERSION = -1;
+
     /** Callback that receives messages forwarded from the context hub. */
     public interface ContextHubCommsCallback {
         /** Indicates whether {@link ChreCommunication} was started successfully. */
@@ -63,19 +70,30 @@
     }
 
     private final Injector mInjector;
+    private final Context mContext;
     private final Executor mExecutor;
 
     private boolean mStarted = false;
+    // null when CHRE availability result has not been returned
+    @Nullable private Boolean mChreSupport = null;
+    private long mNanoAppVersion = INVALID_NANO_APP_VERSION;
     @Nullable private ContextHubCommsCallback mCallback;
     @Nullable private ContextHubClient mContextHubClient;
+    private CountDownLatch mCountDownLatch;
 
-    public ChreCommunication(Injector injector, Executor executor) {
+    public ChreCommunication(Injector injector, Context context, Executor executor) {
         mInjector = injector;
+        mContext = context;
         mExecutor = executor;
     }
 
-    public boolean available() {
-        return mContextHubClient != null;
+    /**
+     * @return {@code true} if NanoApp is available and {@code null} when CHRE availability result
+     * has not been returned
+     */
+    @Nullable
+    public Boolean available() {
+        return mChreSupport;
     }
 
     /**
@@ -86,12 +104,12 @@
      *     contexthub.
      */
     public synchronized void start(ContextHubCommsCallback callback, Set<Long> nanoAppIds) {
-        ContextHubManagerAdapter manager = mInjector.getContextHubManagerAdapter();
+        ContextHubManager manager = mInjector.getContextHubManager();
         if (manager == null) {
             Log.e(TAG, "ContexHub not available in this device");
             return;
         } else {
-            Log.i(TAG, "Start ChreCommunication");
+            Log.i(TAG, "[ChreCommunication] Start ChreCommunication");
         }
         Preconditions.checkNotNull(callback);
         Preconditions.checkArgument(!nanoAppIds.isEmpty());
@@ -134,6 +152,7 @@
         if (mContextHubClient != null) {
             mContextHubClient.close();
             mContextHubClient = null;
+            mChreSupport = null;
         }
     }
 
@@ -156,6 +175,25 @@
         return true;
     }
 
+    /**
+     * Checks the Nano App version
+     */
+    public long queryNanoAppVersion() {
+        if (mCountDownLatch == null || mCountDownLatch.getCount() == 0) {
+            // already gets result from CHRE
+            return mNanoAppVersion;
+        }
+        try {
+            boolean success = mCountDownLatch.await(1, TimeUnit.SECONDS);
+            if (!success) {
+                Log.w(TAG, "Failed to get ContextHubTransaction result before the timeout.");
+            }
+        } catch (InterruptedException e) {
+            e.printStackTrace();
+        }
+        return mNanoAppVersion;
+    }
+
     @Override
     public synchronized void onMessageFromNanoApp(ContextHubClient client, NanoAppMessage message) {
         mCallback.onMessageFromNanoApp(message);
@@ -172,7 +210,8 @@
         mCallback.onNanoAppRestart(nanoAppId);
     }
 
-    private static String contextHubTransactionResultToString(int result) {
+    @VisibleForTesting
+    static String contextHubTransactionResultToString(int result) {
         switch (result) {
             case ContextHubTransaction.RESULT_SUCCESS:
                 return "RESULT_SUCCESS";
@@ -207,13 +246,13 @@
         private final ContextHubInfo mQueriedContextHub;
         private final List<ContextHubInfo> mContextHubs;
         private final Set<Long> mNanoAppIds;
-        private final ContextHubManagerAdapter mManager;
+        private final ContextHubManager mManager;
 
         OnQueryCompleteListener(
                 ContextHubInfo queriedContextHub,
                 List<ContextHubInfo> contextHubs,
                 Set<Long> nanoAppIds,
-                ContextHubManagerAdapter manager) {
+                ContextHubManager manager) {
             this.mQueriedContextHub = queriedContextHub;
             this.mContextHubs = contextHubs;
             this.mNanoAppIds = nanoAppIds;
@@ -231,21 +270,32 @@
                 return;
             }
 
+            mCountDownLatch = new CountDownLatch(1);
             if (response.getResult() == ContextHubTransaction.RESULT_SUCCESS) {
                 for (NanoAppState state : response.getContents()) {
+                    long version = state.getNanoAppVersion();
+                    NearbyConfiguration configuration = new NearbyConfiguration();
+                    long minVersion = configuration.getNanoAppMinVersion();
+                    if (version < minVersion) {
+                        Log.w(TAG, String.format("Current nano app version is %s, which does not  "
+                                + "meet minimum version required %s", version, minVersion));
+                        continue;
+                    }
                     if (mNanoAppIds.contains(state.getNanoAppId())) {
                         Log.i(
                                 TAG,
                                 String.format(
                                         "Found valid contexthub: %s", mQueriedContextHub.getId()));
-                        mContextHubClient =
-                                mManager.createClient(
-                                        mQueriedContextHub, ChreCommunication.this, mExecutor);
+                        mContextHubClient = mManager.createClient(mContext, mQueriedContextHub,
+                                mExecutor, ChreCommunication.this);
+                        mChreSupport = true;
                         mCallback.started(true);
+                        mNanoAppVersion = version;
+                        mCountDownLatch.countDown();
                         return;
                     }
                 }
-                Log.e(
+                Log.i(
                         TAG,
                         String.format(
                                 "Didn't find the nanoapp on contexthub: %s",
@@ -259,10 +309,12 @@
             }
 
             mContextHubs.remove(mQueriedContextHub);
+            mCountDownLatch.countDown();
             // If this is the last context hub response left to receive, indicate that
             // there isn't a valid context available on this device.
             if (mContextHubs.isEmpty()) {
                 mCallback.started(false);
+                mChreSupport = false;
             }
         }
     }
diff --git a/nearby/service/java/com/android/server/nearby/provider/ChreDiscoveryProvider.java b/nearby/service/java/com/android/server/nearby/provider/ChreDiscoveryProvider.java
index f20c6d8..7ab0523 100644
--- a/nearby/service/java/com/android/server/nearby/provider/ChreDiscoveryProvider.java
+++ b/nearby/service/java/com/android/server/nearby/provider/ChreDiscoveryProvider.java
@@ -20,20 +20,33 @@
 
 import static com.android.server.nearby.NearbyService.TAG;
 
+import android.annotation.Nullable;
+import android.content.BroadcastReceiver;
 import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
 import android.hardware.location.NanoAppMessage;
+import android.nearby.DataElement;
 import android.nearby.NearbyDevice;
 import android.nearby.NearbyDeviceParcelable;
+import android.nearby.OffloadCapability;
+import android.nearby.PresenceDevice;
 import android.nearby.PresenceScanFilter;
 import android.nearby.PublicCredential;
 import android.nearby.ScanFilter;
+import android.nearby.aidl.IOffloadCallback;
+import android.os.RemoteException;
 import android.util.Log;
 
+import com.android.internal.annotations.GuardedBy;
 import com.android.internal.annotations.VisibleForTesting;
+import com.android.server.nearby.NearbyConfiguration;
 
-import com.google.protobuf.InvalidProtocolBufferException;
+import com.google.protobuf.ByteString;
 
+import java.util.Arrays;
 import java.util.Collections;
+import java.util.List;
 import java.util.concurrent.Executor;
 
 import service.proto.Blefilter;
@@ -42,52 +55,122 @@
 public class ChreDiscoveryProvider extends AbstractDiscoveryProvider {
     // Nanoapp ID reserved for Nearby Presence.
     /** @hide */
-    @VisibleForTesting public static final long NANOAPP_ID = 0x476f6f676c001031L;
+    @VisibleForTesting
+    public static final long NANOAPP_ID = 0x476f6f676c001031L;
     /** @hide */
-    @VisibleForTesting public static final int NANOAPP_MESSAGE_TYPE_FILTER = 3;
+    @VisibleForTesting
+    public static final int NANOAPP_MESSAGE_TYPE_FILTER = 3;
     /** @hide */
-    @VisibleForTesting public static final int NANOAPP_MESSAGE_TYPE_FILTER_RESULT = 4;
+    @VisibleForTesting
+    public static final int NANOAPP_MESSAGE_TYPE_FILTER_RESULT = 4;
+    /** @hide */
+    @VisibleForTesting
+    public static final int NANOAPP_MESSAGE_TYPE_CONFIG = 5;
 
-    private static final int PRESENCE_UUID = 0xFCF1;
+    private final ChreCommunication mChreCommunication;
+    private final ChreCallback mChreCallback;
+    private final Object mLock = new Object();
 
-    private ChreCommunication mChreCommunication;
-    private ChreCallback mChreCallback;
     private boolean mChreStarted = false;
-    private Blefilter.BleFilters mFilters = null;
-    private int mFilterId;
+    private Context mContext;
+    private NearbyConfiguration mNearbyConfiguration;
+    private final IntentFilter mIntentFilter;
+    // Null when CHRE not started and the filters are never set. Empty the list every time the scan
+    // stops.
+    @GuardedBy("mLock")
+    @Nullable
+    private List<ScanFilter> mScanFilters;
+
+    private final BroadcastReceiver mScreenBroadcastReceiver =
+            new BroadcastReceiver() {
+                @Override
+                public void onReceive(Context context, Intent intent) {
+                    Boolean screenOn = intent.getAction().equals(Intent.ACTION_SCREEN_ON)
+                            || intent.getAction().equals(Intent.ACTION_USER_PRESENT);
+                    Log.d(TAG, String.format(
+                            "[ChreDiscoveryProvider] update nanoapp screen status: %B", screenOn));
+                    sendScreenUpdate(screenOn);
+                }
+            };
 
     public ChreDiscoveryProvider(
             Context context, ChreCommunication chreCommunication, Executor executor) {
         super(context, executor);
+        mContext = context;
         mChreCommunication = chreCommunication;
         mChreCallback = new ChreCallback();
-        mFilterId = 0;
+        mIntentFilter = new IntentFilter();
+    }
+
+    /** Initialize the CHRE discovery provider. */
+    public void init() {
+        mChreCommunication.start(mChreCallback, Collections.singleton(NANOAPP_ID));
+        mNearbyConfiguration = new NearbyConfiguration();
     }
 
     @Override
     protected void onStart() {
         Log.d(TAG, "Start CHRE scan");
-        mChreCommunication.start(mChreCallback, Collections.singleton(NANOAPP_ID));
-        updateFilters();
+        synchronized (mLock) {
+            updateFiltersLocked();
+        }
     }
 
     @Override
     protected void onStop() {
-        mChreStarted = false;
-        mChreCommunication.stop();
+        Log.d(TAG, "Stop CHRE scan");
+        synchronized (mLock) {
+            if (mScanFilters != null) {
+                // Cleaning the filters by assigning an empty list
+                mScanFilters = List.of();
+            }
+            updateFiltersLocked();
+        }
     }
 
     @Override
-    protected void invalidateScanMode() {
-        onStop();
-        onStart();
+    protected void onSetScanFilters(List<ScanFilter> filters) {
+        synchronized (mLock) {
+            mScanFilters = filters == null ? null : List.copyOf(filters);
+            updateFiltersLocked();
+        }
     }
 
-    public boolean available() {
+    /**
+     * @return {@code true} if CHRE is available and {@code null} when CHRE availability result
+     * has not been returned
+     */
+    @Nullable
+    public Boolean available() {
         return mChreCommunication.available();
     }
 
-    private synchronized void updateFilters() {
+    /**
+     * Query offload capability in a device.
+     */
+    public void queryOffloadCapability(IOffloadCallback callback) {
+        OffloadCapability.Builder builder = new OffloadCapability.Builder();
+        mExecutor.execute(() -> {
+            long version = mChreCommunication.queryNanoAppVersion();
+            builder.setVersion(version);
+            builder.setFastPairSupported(version != ChreCommunication.INVALID_NANO_APP_VERSION);
+            try {
+                callback.onQueryComplete(builder.build());
+            } catch (RemoteException e) {
+                e.printStackTrace();
+            }
+        });
+    }
+
+    @VisibleForTesting
+    public List<ScanFilter> getFiltersLocked() {
+        synchronized (mLock) {
+            return mScanFilters == null ? null : List.copyOf(mScanFilters);
+        }
+    }
+
+    @GuardedBy("mLock")
+    private void updateFiltersLocked() {
         if (mScanFilters == null) {
             Log.e(TAG, "ScanFilters not set.");
             return;
@@ -95,29 +178,69 @@
         Blefilter.BleFilters.Builder filtersBuilder = Blefilter.BleFilters.newBuilder();
         for (ScanFilter scanFilter : mScanFilters) {
             PresenceScanFilter presenceScanFilter = (PresenceScanFilter) scanFilter;
-            Blefilter.BleFilter filter =
-                    Blefilter.BleFilter.newBuilder()
-                            .setId(mFilterId)
-                            .setUuid(PRESENCE_UUID)
-                            .setIntent(presenceScanFilter.getPresenceActions().get(0))
-                            .build();
-            filtersBuilder.addFilter(filter);
-            mFilterId++;
+            Blefilter.BleFilter.Builder filterBuilder = Blefilter.BleFilter.newBuilder();
+            for (PublicCredential credential : presenceScanFilter.getCredentials()) {
+                filterBuilder.addCertificate(toProtoPublicCredential(credential));
+            }
+            for (DataElement dataElement : presenceScanFilter.getExtendedProperties()) {
+                if (dataElement.getKey() == DataElement.DataType.ACCOUNT_KEY_DATA) {
+                    filterBuilder.addDataElement(toProtoDataElement(dataElement));
+                } else if (mNearbyConfiguration.isTestAppSupported()
+                        && DataElement.isTestDeType(dataElement.getKey())) {
+                    filterBuilder.addDataElement(toProtoDataElement(dataElement));
+                }
+            }
+            if (!presenceScanFilter.getPresenceActions().isEmpty()) {
+                filterBuilder.setIntent(presenceScanFilter.getPresenceActions().get(0));
+            }
+            filtersBuilder.addFilter(filterBuilder.build());
         }
-        mFilters = filtersBuilder.build();
         if (mChreStarted) {
-            sendFilters(mFilters);
-            mFilters = null;
+            sendFilters(filtersBuilder.build());
         }
     }
 
+    private Blefilter.PublicateCertificate toProtoPublicCredential(PublicCredential credential) {
+        Log.d(TAG, String.format("Returns a PublicCertificate with authenticity key size %d and"
+                        + " encrypted metadata key tag size %d",
+                credential.getAuthenticityKey().length,
+                credential.getEncryptedMetadataKeyTag().length));
+        return Blefilter.PublicateCertificate.newBuilder()
+                .setAuthenticityKey(ByteString.copyFrom(credential.getAuthenticityKey()))
+                .setMetadataEncryptionKeyTag(
+                        ByteString.copyFrom(credential.getEncryptedMetadataKeyTag()))
+                .build();
+    }
+
+    private Blefilter.DataElement toProtoDataElement(DataElement dataElement) {
+        return Blefilter.DataElement.newBuilder()
+                .setKey(dataElement.getKey())
+                .setValue(ByteString.copyFrom(dataElement.getValue()))
+                .setValueLength(dataElement.getValue().length)
+                .build();
+    }
+
     private void sendFilters(Blefilter.BleFilters filters) {
         NanoAppMessage message =
                 NanoAppMessage.createMessageToNanoApp(
                         NANOAPP_ID, NANOAPP_MESSAGE_TYPE_FILTER, filters.toByteArray());
-        if (!mChreCommunication.sendMessageToNanoApp(message)) {
-            Log.e(TAG, "Failed to send filters to CHRE.");
+        if (mChreCommunication.sendMessageToNanoApp(message)) {
+            Log.v(TAG, "Successfully sent filters to CHRE.");
+            return;
         }
+        Log.e(TAG, "Failed to send filters to CHRE.");
+    }
+
+    private void sendScreenUpdate(Boolean screenOn) {
+        Blefilter.BleConfig config = Blefilter.BleConfig.newBuilder().setScreenOn(screenOn).build();
+        NanoAppMessage message =
+                NanoAppMessage.createMessageToNanoApp(
+                        NANOAPP_ID, NANOAPP_MESSAGE_TYPE_CONFIG, config.toByteArray());
+        if (mChreCommunication.sendMessageToNanoApp(message)) {
+            Log.v(TAG, "Successfully sent config to CHRE.");
+            return;
+        }
+        Log.e(TAG, "Failed to send config to CHRE.");
     }
 
     private class ChreCallback implements ChreCommunication.ContextHubCommsCallback {
@@ -127,11 +250,11 @@
             if (success) {
                 synchronized (ChreDiscoveryProvider.this) {
                     Log.i(TAG, "CHRE communication started");
+                    mIntentFilter.addAction(Intent.ACTION_SCREEN_ON);
+                    mIntentFilter.addAction(Intent.ACTION_USER_PRESENT);
+                    mIntentFilter.addAction(Intent.ACTION_SCREEN_OFF);
+                    mContext.registerReceiver(mScreenBroadcastReceiver, mIntentFilter);
                     mChreStarted = true;
-                    if (mFilters != null) {
-                        sendFilters(mFilters);
-                        mFilters = null;
-                    }
                 }
             }
         }
@@ -163,32 +286,123 @@
                     Blefilter.BleFilterResults results =
                             Blefilter.BleFilterResults.parseFrom(message.getMessageBody());
                     for (Blefilter.BleFilterResult filterResult : results.getResultList()) {
-                        Blefilter.PublicCredential credential = filterResult.getPublicCredential();
+                        // TODO(b/234653356): There are some duplicate fields set both in
+                        //  PresenceDevice and NearbyDeviceParcelable, cleanup is needed.
+                        byte[] salt = {1};
+                        byte[] secretId = {1};
+                        byte[] authenticityKey = {1};
+                        byte[] publicKey = {1};
+                        byte[] encryptedMetaData = {1};
+                        byte[] encryptedMetaDataTag = {1};
+                        if (filterResult.hasPublicCredential()) {
+                            Blefilter.PublicCredential credential =
+                                    filterResult.getPublicCredential();
+                            secretId = credential.getSecretId().toByteArray();
+                            authenticityKey = credential.getAuthenticityKey().toByteArray();
+                            publicKey = credential.getPublicKey().toByteArray();
+                            encryptedMetaData = credential.getEncryptedMetadata().toByteArray();
+                            encryptedMetaDataTag =
+                                    credential.getEncryptedMetadataTag().toByteArray();
+                        }
+                        PresenceDevice.Builder presenceDeviceBuilder =
+                                new PresenceDevice.Builder(
+                                        String.valueOf(filterResult.hashCode()),
+                                        salt,
+                                        secretId,
+                                        encryptedMetaData)
+                                        .setRssi(filterResult.getRssi())
+                                        .addMedium(NearbyDevice.Medium.BLE);
+                        // Data Elements reported from nanoapp added to Data Elements.
+                        // i.e. Fast Pair account keys, connection status and battery
+                        for (Blefilter.DataElement element : filterResult.getDataElementList()) {
+                            addDataElementsToPresenceDevice(element, presenceDeviceBuilder);
+                        }
+                        // BlE address appended to Data Element.
+                        if (filterResult.hasBluetoothAddress()) {
+                            presenceDeviceBuilder.addExtendedProperty(
+                                    new DataElement(
+                                            DataElement.DataType.BLE_ADDRESS,
+                                            filterResult.getBluetoothAddress().toByteArray()));
+                        }
+                        // BlE TX Power appended to Data Element.
+                        if (filterResult.hasTxPower()) {
+                            presenceDeviceBuilder.addExtendedProperty(
+                                    new DataElement(
+                                            DataElement.DataType.TX_POWER,
+                                            new byte[]{(byte) filterResult.getTxPower()}));
+                        }
+                        // BLE Service data appended to Data Elements.
+                        if (filterResult.hasBleServiceData()) {
+                            // Retrieves the length of the service data from the first byte,
+                            // and then skips the first byte and returns data[1 .. dataLength)
+                            // as the DataElement value.
+                            int dataLength = Byte.toUnsignedInt(
+                                    filterResult.getBleServiceData().byteAt(0));
+                            presenceDeviceBuilder.addExtendedProperty(
+                                    new DataElement(
+                                            DataElement.DataType.BLE_SERVICE_DATA,
+                                            filterResult.getBleServiceData()
+                                                    .substring(1, 1 + dataLength).toByteArray()));
+                        }
+                        // Add action
+                        if (filterResult.hasIntent()) {
+                            presenceDeviceBuilder.addExtendedProperty(
+                                    new DataElement(
+                                            DataElement.DataType.ACTION,
+                                            new byte[]{(byte) filterResult.getIntent()}));
+                        }
+
                         PublicCredential publicCredential =
                                 new PublicCredential.Builder(
-                                                credential.getSecretId().toByteArray(),
-                                                credential.getAuthenticityKey().toByteArray(),
-                                                credential.getPublicKey().toByteArray(),
-                                                credential.getEncryptedMetadata().toByteArray(),
-                                                credential.getEncryptedMetadataTag().toByteArray())
+                                        secretId,
+                                        authenticityKey,
+                                        publicKey,
+                                        encryptedMetaData,
+                                        encryptedMetaDataTag)
                                         .build();
+
                         NearbyDeviceParcelable device =
                                 new NearbyDeviceParcelable.Builder()
+                                        .setDeviceId(Arrays.hashCode(secretId))
                                         .setScanType(SCAN_TYPE_NEARBY_PRESENCE)
                                         .setMedium(NearbyDevice.Medium.BLE)
                                         .setTxPower(filterResult.getTxPower())
                                         .setRssi(filterResult.getRssi())
                                         .setAction(filterResult.getIntent())
                                         .setPublicCredential(publicCredential)
+                                        .setPresenceDevice(presenceDeviceBuilder.build())
+                                        .setEncryptionKeyTag(encryptedMetaDataTag)
                                         .build();
                         mExecutor.execute(() -> mListener.onNearbyDeviceDiscovered(device));
                     }
-                } catch (InvalidProtocolBufferException e) {
-                    Log.e(
-                            TAG,
-                            String.format("Failed to decode the filter result %s", e.toString()));
+                } catch (Exception e) {
+                    Log.e(TAG, String.format("Failed to decode the filter result %s", e));
                 }
             }
         }
+
+        private void addDataElementsToPresenceDevice(Blefilter.DataElement element,
+                PresenceDevice.Builder presenceDeviceBuilder) {
+            int endIndex = element.hasValueLength() ? element.getValueLength() :
+                    element.getValue().size();
+            int key = element.getKey();
+            switch (key) {
+                case DataElement.DataType.ACCOUNT_KEY_DATA:
+                case DataElement.DataType.CONNECTION_STATUS:
+                case DataElement.DataType.BATTERY:
+                    presenceDeviceBuilder.addExtendedProperty(
+                            new DataElement(key,
+                                    element.getValue().substring(0, endIndex).toByteArray()));
+                    break;
+                default:
+                    if (mNearbyConfiguration.isTestAppSupported()
+                            && DataElement.isTestDeType(key)) {
+                        presenceDeviceBuilder.addExtendedProperty(
+                                new DataElement(key,
+                                        element.getValue().substring(0, endIndex).toByteArray()));
+                    }
+                    break;
+            }
+        }
     }
 }
diff --git a/nearby/service/java/com/android/server/nearby/provider/DiscoveryProviderController.java b/nearby/service/java/com/android/server/nearby/provider/DiscoveryProviderController.java
index fa1a874..71ffda5 100644
--- a/nearby/service/java/com/android/server/nearby/provider/DiscoveryProviderController.java
+++ b/nearby/service/java/com/android/server/nearby/provider/DiscoveryProviderController.java
@@ -23,7 +23,7 @@
 import java.util.List;
 
 /** Interface for controlling discovery providers. */
-interface DiscoveryProviderController {
+public interface DiscoveryProviderController {
 
     /**
      * Sets the listener which can expect to receive all state updates from after this point. May be
diff --git a/nearby/service/java/com/android/server/nearby/provider/DiscoveryProviderManager.java b/nearby/service/java/com/android/server/nearby/provider/DiscoveryProviderManager.java
deleted file mode 100644
index bdeab51..0000000
--- a/nearby/service/java/com/android/server/nearby/provider/DiscoveryProviderManager.java
+++ /dev/null
@@ -1,332 +0,0 @@
-/*
- * Copyright (C) 2021 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.provider;
-
-import static android.nearby.ScanRequest.SCAN_TYPE_NEARBY_PRESENCE;
-
-import static com.android.server.nearby.NearbyService.TAG;
-
-import android.annotation.Nullable;
-import android.app.AppOpsManager;
-import android.content.Context;
-import android.nearby.IScanListener;
-import android.nearby.NearbyDeviceParcelable;
-import android.nearby.PresenceScanFilter;
-import android.nearby.ScanFilter;
-import android.nearby.ScanRequest;
-import android.os.IBinder;
-import android.os.RemoteException;
-import android.util.Log;
-
-import com.android.internal.annotations.GuardedBy;
-import com.android.server.nearby.injector.Injector;
-import com.android.server.nearby.metrics.NearbyMetrics;
-import com.android.server.nearby.presence.PresenceDiscoveryResult;
-import com.android.server.nearby.util.identity.CallerIdentity;
-import com.android.server.nearby.util.permissions.DiscoveryPermissions;
-
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Objects;
-import java.util.concurrent.Executor;
-import java.util.concurrent.Executors;
-import java.util.stream.Collectors;
-
-/** Manages all aspects of discovery providers. */
-public class DiscoveryProviderManager implements AbstractDiscoveryProvider.Listener {
-
-    protected final Object mLock = new Object();
-    private final Context mContext;
-    private final BleDiscoveryProvider mBleDiscoveryProvider;
-    @Nullable private final ChreDiscoveryProvider mChreDiscoveryProvider;
-    private @ScanRequest.ScanMode int mScanMode;
-    private final Injector mInjector;
-
-    @GuardedBy("mLock")
-    private Map<IBinder, ScanListenerRecord> mScanTypeScanListenerRecordMap;
-
-    @Override
-    public void onNearbyDeviceDiscovered(NearbyDeviceParcelable nearbyDevice) {
-        synchronized (mLock) {
-            AppOpsManager appOpsManager = Objects.requireNonNull(mInjector.getAppOpsManager());
-            for (IBinder listenerBinder : mScanTypeScanListenerRecordMap.keySet()) {
-                ScanListenerRecord record = mScanTypeScanListenerRecordMap.get(listenerBinder);
-                if (record == null) {
-                    Log.w(TAG, "DiscoveryProviderManager cannot find the scan record.");
-                    continue;
-                }
-                CallerIdentity callerIdentity = record.getCallerIdentity();
-                if (!DiscoveryPermissions.noteDiscoveryResultDelivery(
-                        appOpsManager, callerIdentity)) {
-                    Log.w(TAG, "[DiscoveryProviderManager] scan permission revoked "
-                            + "- not forwarding results");
-                    try {
-                        record.getScanListener().onError();
-                    } catch (RemoteException e) {
-                        Log.w(TAG, "DiscoveryProviderManager failed to report error.", e);
-                    }
-                    return;
-                }
-
-                if (nearbyDevice.getScanType() == SCAN_TYPE_NEARBY_PRESENCE) {
-                    List<ScanFilter> presenceFilters =
-                            record.getScanRequest().getScanFilters().stream()
-                                    .filter(
-                                            scanFilter ->
-                                                    scanFilter.getType()
-                                                            == SCAN_TYPE_NEARBY_PRESENCE)
-                                    .collect(Collectors.toList());
-                    Log.i(
-                            TAG,
-                            String.format("match with filters size: %d", presenceFilters.size()));
-                    if (!presenceFilterMatches(nearbyDevice, presenceFilters)) {
-                        continue;
-                    }
-                }
-                try {
-                    record.getScanListener()
-                            .onDiscovered(
-                                    PrivacyFilter.filter(
-                                            record.getScanRequest().getScanType(), nearbyDevice));
-                    NearbyMetrics.logScanDeviceDiscovered(
-                            record.hashCode(), record.getScanRequest(), nearbyDevice);
-                } catch (RemoteException e) {
-                    Log.w(TAG, "DiscoveryProviderManager failed to report onDiscovered.", e);
-                }
-            }
-        }
-    }
-
-    public DiscoveryProviderManager(Context context, Injector injector) {
-        mContext = context;
-        mBleDiscoveryProvider = new BleDiscoveryProvider(mContext, injector);
-        Executor executor = Executors.newSingleThreadExecutor();
-        mChreDiscoveryProvider =
-                new ChreDiscoveryProvider(
-                        mContext, new ChreCommunication(injector, executor), executor);
-        mScanTypeScanListenerRecordMap = new HashMap<>();
-        mInjector = injector;
-    }
-
-    /**
-     * Registers the listener in the manager and starts scan according to the requested scan mode.
-     */
-    public boolean registerScanListener(ScanRequest scanRequest, IScanListener listener,
-            CallerIdentity callerIdentity) {
-        synchronized (mLock) {
-            IBinder listenerBinder = listener.asBinder();
-            if (mScanTypeScanListenerRecordMap.containsKey(listener.asBinder())) {
-                ScanRequest savedScanRequest =
-                        mScanTypeScanListenerRecordMap.get(listenerBinder).getScanRequest();
-                if (scanRequest.equals(savedScanRequest)) {
-                    Log.d(TAG, "Already registered the scanRequest: " + scanRequest);
-                    return true;
-                }
-            }
-            ScanListenerRecord scanListenerRecord =
-                    new ScanListenerRecord(scanRequest, listener, callerIdentity);
-            mScanTypeScanListenerRecordMap.put(listenerBinder, scanListenerRecord);
-
-            if (!startProviders(scanRequest)) {
-                return false;
-            }
-
-            NearbyMetrics.logScanStarted(scanListenerRecord.hashCode(), scanRequest);
-            if (mScanMode < scanRequest.getScanMode()) {
-                mScanMode = scanRequest.getScanMode();
-                invalidateProviderScanMode();
-            }
-            return true;
-        }
-    }
-
-    /**
-     * Unregisters the listener in the manager and adjusts the scan mode if necessary afterwards.
-     */
-    public void unregisterScanListener(IScanListener listener) {
-        IBinder listenerBinder = listener.asBinder();
-        synchronized (mLock) {
-            if (!mScanTypeScanListenerRecordMap.containsKey(listenerBinder)) {
-                Log.w(
-                        TAG,
-                        "Cannot unregister the scanRequest because the request is never "
-                                + "registered.");
-                return;
-            }
-
-            ScanListenerRecord removedRecord =
-                    mScanTypeScanListenerRecordMap.remove(listenerBinder);
-            Log.v(TAG, "DiscoveryProviderManager unregistered scan listener.");
-            NearbyMetrics.logScanStopped(removedRecord.hashCode(), removedRecord.getScanRequest());
-            if (mScanTypeScanListenerRecordMap.isEmpty()) {
-                Log.v(TAG, "DiscoveryProviderManager stops provider because there is no "
-                        + "scan listener registered.");
-                stopProviders();
-                return;
-            }
-
-            // TODO(b/221082271): updates the scan with reduced filters.
-
-            // Removes current highest scan mode requested and sets the next highest scan mode.
-            if (removedRecord.getScanRequest().getScanMode() == mScanMode) {
-                Log.v(TAG, "DiscoveryProviderManager starts to find the new highest scan mode "
-                        + "because the highest scan mode listener was unregistered.");
-                @ScanRequest.ScanMode int highestScanModeRequested = ScanRequest.SCAN_MODE_NO_POWER;
-                // find the next highest scan mode;
-                for (ScanListenerRecord record : mScanTypeScanListenerRecordMap.values()) {
-                    @ScanRequest.ScanMode int scanMode = record.getScanRequest().getScanMode();
-                    if (scanMode > highestScanModeRequested) {
-                        highestScanModeRequested = scanMode;
-                    }
-                }
-                if (mScanMode != highestScanModeRequested) {
-                    mScanMode = highestScanModeRequested;
-                    invalidateProviderScanMode();
-                }
-            }
-        }
-    }
-
-    // Returns false when fail to start all the providers. Returns true if any one of the provider
-    // starts successfully.
-    private boolean startProviders(ScanRequest scanRequest) {
-        if (scanRequest.isBleEnabled()) {
-            if (mChreDiscoveryProvider.available()
-                    && scanRequest.getScanType() == SCAN_TYPE_NEARBY_PRESENCE) {
-                startChreProvider();
-            } else {
-                startBleProvider(scanRequest);
-            }
-            return true;
-        }
-        return false;
-    }
-
-    private void startBleProvider(ScanRequest scanRequest) {
-        if (!mBleDiscoveryProvider.getController().isStarted()) {
-            Log.d(TAG, "DiscoveryProviderManager starts Ble scanning.");
-            mBleDiscoveryProvider.getController().start();
-            mBleDiscoveryProvider.getController().setListener(this);
-            mBleDiscoveryProvider.getController().setProviderScanMode(scanRequest.getScanMode());
-        }
-    }
-
-    private void startChreProvider() {
-        Log.d(TAG, "DiscoveryProviderManager starts CHRE scanning.");
-        synchronized (mLock) {
-            mChreDiscoveryProvider.getController().setListener(this);
-            List<ScanFilter> scanFilters = new ArrayList();
-            for (IBinder listenerBinder : mScanTypeScanListenerRecordMap.keySet()) {
-                ScanListenerRecord record = mScanTypeScanListenerRecordMap.get(listenerBinder);
-                List<ScanFilter> presenceFilters =
-                        record.getScanRequest().getScanFilters().stream()
-                                .filter(
-                                        scanFilter ->
-                                                scanFilter.getType() == SCAN_TYPE_NEARBY_PRESENCE)
-                                .collect(Collectors.toList());
-                scanFilters.addAll(presenceFilters);
-            }
-            mChreDiscoveryProvider.getController().setProviderScanFilters(scanFilters);
-            mChreDiscoveryProvider.getController().setProviderScanMode(mScanMode);
-            mChreDiscoveryProvider.getController().start();
-        }
-    }
-
-    private void stopProviders() {
-        stopBleProvider();
-        stopChreProvider();
-    }
-
-    private void stopBleProvider() {
-        mBleDiscoveryProvider.getController().stop();
-    }
-
-    private void stopChreProvider() {
-        mChreDiscoveryProvider.getController().stop();
-    }
-
-    private void invalidateProviderScanMode() {
-        if (mBleDiscoveryProvider.getController().isStarted()) {
-            mBleDiscoveryProvider.getController().setProviderScanMode(mScanMode);
-        } else {
-            Log.d(
-                    TAG,
-                    "Skip invalidating BleDiscoveryProvider scan mode because the provider not "
-                            + "started.");
-        }
-    }
-
-    private static boolean presenceFilterMatches(
-            NearbyDeviceParcelable device, List<ScanFilter> scanFilters) {
-        if (scanFilters.isEmpty()) {
-            return true;
-        }
-        PresenceDiscoveryResult discoveryResult = PresenceDiscoveryResult.fromDevice(device);
-        for (ScanFilter scanFilter : scanFilters) {
-            PresenceScanFilter presenceScanFilter = (PresenceScanFilter) scanFilter;
-            if (discoveryResult.matches(presenceScanFilter)) {
-                return true;
-            }
-        }
-        return false;
-    }
-
-    private static class ScanListenerRecord {
-
-        private final ScanRequest mScanRequest;
-
-        private final IScanListener mScanListener;
-
-        private final CallerIdentity mCallerIdentity;
-
-        ScanListenerRecord(ScanRequest scanRequest, IScanListener iScanListener,
-                CallerIdentity callerIdentity) {
-            mScanListener = iScanListener;
-            mScanRequest = scanRequest;
-            mCallerIdentity = callerIdentity;
-        }
-
-        IScanListener getScanListener() {
-            return mScanListener;
-        }
-
-        ScanRequest getScanRequest() {
-            return mScanRequest;
-        }
-
-        CallerIdentity getCallerIdentity() {
-            return mCallerIdentity;
-        }
-
-        @Override
-        public boolean equals(Object other) {
-            if (other instanceof ScanListenerRecord) {
-                ScanListenerRecord otherScanListenerRecord = (ScanListenerRecord) other;
-                return Objects.equals(mScanRequest, otherScanListenerRecord.mScanRequest)
-                        && Objects.equals(mScanListener, otherScanListenerRecord.mScanListener);
-            }
-            return false;
-        }
-
-        @Override
-        public int hashCode() {
-            return Objects.hash(mScanListener, mScanRequest);
-        }
-    }
-}
diff --git a/nearby/service/java/com/android/server/nearby/util/ArrayUtils.java b/nearby/service/java/com/android/server/nearby/util/ArrayUtils.java
index 599843c..35251d8 100644
--- a/nearby/service/java/com/android/server/nearby/util/ArrayUtils.java
+++ b/nearby/service/java/com/android/server/nearby/util/ArrayUtils.java
@@ -45,4 +45,11 @@
         }
         return result;
     }
+
+    /**
+     * @return true when the array is null or length is 0
+     */
+    public static boolean isEmpty(byte[] bytes) {
+        return bytes == null || bytes.length == 0;
+    }
 }
diff --git a/nearby/service/java/com/android/server/nearby/util/encryption/Cryptor.java b/nearby/service/java/com/android/server/nearby/util/encryption/Cryptor.java
new file mode 100644
index 0000000..3c5132d
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/util/encryption/Cryptor.java
@@ -0,0 +1,149 @@
+/*
+ * 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.nearby.util.encryption;
+
+import static com.android.server.nearby.NearbyService.TAG;
+
+import android.util.Log;
+
+import androidx.annotation.Nullable;
+
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+
+/** Class for encryption/decryption functionality. */
+public abstract class Cryptor {
+
+    /** AES only supports key sizes of 16, 24 or 32 bytes. */
+    static final int AUTHENTICITY_KEY_BYTE_SIZE = 16;
+
+    private static final String HMAC_SHA256_ALGORITHM = "HmacSHA256";
+
+    /**
+     * Encrypt the provided data blob.
+     *
+     * @param data data blob to be encrypted.
+     * @param salt used for IV
+     * @param secretKeyBytes secrete key accessed from credentials
+     * @return encrypted data, {@code null} if failed to encrypt.
+     */
+    @Nullable
+    public byte[] encrypt(byte[] data, byte[] salt, byte[] secretKeyBytes) {
+        return data;
+    }
+
+    /**
+     * Decrypt the original data blob from the provided byte array.
+     *
+     * @param encryptedData data blob to be decrypted.
+     * @param salt used for IV
+     * @param secretKeyBytes secrete key accessed from credentials
+     * @return decrypted data, {@code null} if failed to decrypt.
+     */
+    @Nullable
+    public byte[] decrypt(byte[] encryptedData, byte[] salt, byte[] secretKeyBytes) {
+        return encryptedData;
+    }
+
+    /**
+     * Generates a digital signature for the data.
+     *
+     * @return signature {@code null} if failed to sign
+     */
+    @Nullable
+    public byte[] sign(byte[] data, byte[] key) {
+        return new byte[0];
+    }
+
+    /**
+     * Verifies the signature generated by data and key, with the original signed data
+     */
+    public boolean verify(byte[] data, byte[] key, byte[] signature) {
+        return true;
+    }
+
+    /**
+     * @return length of the signature generated
+     */
+    public int getSignatureLength() {
+        return 0;
+    }
+
+    /**
+     * A HAMC sha256 based HKDF algorithm to pseudo randomly hash data and salt into a byte array of
+     * given size.
+     */
+    // Based on google3/third_party/tink/java/src/main/java/com/google/crypto/tink/subtle/Hkdf.java
+    @Nullable
+    static byte[] computeHkdf(byte[] ikm, byte[] salt, int size) {
+        Mac mac;
+        try {
+            mac = Mac.getInstance(HMAC_SHA256_ALGORITHM);
+        } catch (NoSuchAlgorithmException e) {
+            Log.w(TAG, "HMAC_SHA256_ALGORITHM is not supported.", e);
+            return null;
+        }
+
+        if (size > 255 * mac.getMacLength()) {
+            Log.w(TAG, "Size too large.");
+            return null;
+        }
+
+        if (salt.length == 0) {
+            Log.w(TAG, "Salt cannot be empty.");
+            return null;
+        }
+
+        try {
+            mac.init(new SecretKeySpec(salt, HMAC_SHA256_ALGORITHM));
+        } catch (InvalidKeyException e) {
+            Log.w(TAG, "Invalid key.", e);
+            return null;
+        }
+
+        byte[] prk = mac.doFinal(ikm);
+        byte[] result = new byte[size];
+        try {
+            mac.init(new SecretKeySpec(prk, HMAC_SHA256_ALGORITHM));
+        } catch (InvalidKeyException e) {
+            Log.w(TAG, "Invalid key.", e);
+            return null;
+        }
+
+        byte[] digest = new byte[0];
+        int ctr = 1;
+        int pos = 0;
+        while (true) {
+            mac.update(digest);
+            mac.update((byte) ctr);
+            digest = mac.doFinal();
+            if (pos + digest.length < size) {
+                System.arraycopy(digest, 0, result, pos, digest.length);
+                pos += digest.length;
+                ctr++;
+            } else {
+                System.arraycopy(digest, 0, result, pos, size - pos);
+                break;
+            }
+        }
+
+        return result;
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/util/encryption/CryptorImpFake.java b/nearby/service/java/com/android/server/nearby/util/encryption/CryptorImpFake.java
new file mode 100644
index 0000000..1c0ec9e
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/util/encryption/CryptorImpFake.java
@@ -0,0 +1,39 @@
+/*
+ * 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.nearby.util.encryption;
+
+import androidx.annotation.Nullable;
+
+/**
+ * A Cryptor that returns the original data without actual encryption
+ */
+public class CryptorImpFake extends Cryptor {
+    // Lazily instantiated when {@link #getInstance()} is called.
+    @Nullable
+    private static CryptorImpFake sCryptor;
+
+    /** Returns an instance of CryptorImpFake. */
+    public static CryptorImpFake getInstance() {
+        if (sCryptor == null) {
+            sCryptor = new CryptorImpFake();
+        }
+        return sCryptor;
+    }
+
+    private CryptorImpFake() {
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/util/encryption/CryptorImpIdentityV1.java b/nearby/service/java/com/android/server/nearby/util/encryption/CryptorImpIdentityV1.java
new file mode 100644
index 0000000..b0e19b4
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/util/encryption/CryptorImpIdentityV1.java
@@ -0,0 +1,208 @@
+/*
+ * 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.nearby.util.encryption;
+
+import static com.android.server.nearby.NearbyService.TAG;
+
+import android.security.keystore.KeyProperties;
+import android.util.Log;
+
+import androidx.annotation.Nullable;
+
+import com.android.internal.annotations.VisibleForTesting;
+
+import java.security.InvalidAlgorithmParameterException;
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+import java.util.Arrays;
+
+import javax.crypto.BadPaddingException;
+import javax.crypto.Cipher;
+import javax.crypto.IllegalBlockSizeException;
+import javax.crypto.NoSuchPaddingException;
+import javax.crypto.SecretKey;
+import javax.crypto.spec.IvParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
+
+/**
+ * {@link android.nearby.BroadcastRequest#PRESENCE_VERSION_V1} for identity
+ * encryption and decryption.
+ */
+public class CryptorImpIdentityV1 extends Cryptor {
+
+    // 3 16 byte arrays known by both the encryptor and decryptor.
+    private static final byte[] EK_IV =
+            new byte[] {14, -123, -39, 42, 109, 127, 83, 27, 27, 11, 91, -38, 92, 17, -84, 66};
+    private static final byte[] ESALT_IV =
+            new byte[] {46, 83, -19, 10, -127, -31, -31, 12, 31, 76, 63, -9, 33, -66, 15, -10};
+    private static final byte[] KTAG_IV =
+            {-22, -83, -6, 67, 16, -99, -13, -9, 8, -3, -16, 37, -75, 47, 1, -56};
+
+    /** Length of encryption key required by AES/GCM encryption. */
+    private static final int ENCRYPTION_KEY_SIZE = 32;
+
+    /** Length of salt required by AES/GCM encryption. */
+    private static final int AES_CTR_IV_SIZE = 16;
+
+    /** Length HMAC tag */
+    public static final int HMAC_TAG_SIZE = 8;
+
+    /**
+     * In the form of "algorithm/mode/padding". Must be the same across broadcast and scan devices.
+     */
+    private static final String CIPHER_ALGORITHM = "AES/CTR/NoPadding";
+
+    @VisibleForTesting
+    static final String ENCRYPT_ALGORITHM = KeyProperties.KEY_ALGORITHM_AES;
+
+    // Lazily instantiated when {@link #getInstance()} is called.
+    @Nullable private static CryptorImpIdentityV1 sCryptor;
+
+    /** Returns an instance of CryptorImpIdentityV1. */
+    public static CryptorImpIdentityV1 getInstance() {
+        if (sCryptor == null) {
+            sCryptor = new CryptorImpIdentityV1();
+        }
+        return sCryptor;
+    }
+
+    @Nullable
+    @Override
+    public byte[] encrypt(byte[] data, byte[] salt, byte[] authenticityKey) {
+        if (authenticityKey.length != AUTHENTICITY_KEY_BYTE_SIZE) {
+            Log.w(TAG, "Illegal authenticity key size");
+            return null;
+        }
+
+        // Generates a 32 bytes encryption key from authenticity_key
+        byte[] encryptionKey = Cryptor.computeHkdf(authenticityKey, EK_IV, ENCRYPTION_KEY_SIZE);
+        if (encryptionKey == null) {
+            Log.e(TAG, "Failed to generate encryption key.");
+            return null;
+        }
+
+        // Encrypts the data using the encryption key
+        SecretKey secretKey = new SecretKeySpec(encryptionKey, ENCRYPT_ALGORITHM);
+        Cipher cipher;
+        try {
+            cipher = Cipher.getInstance(CIPHER_ALGORITHM);
+        } catch (NoSuchAlgorithmException | NoSuchPaddingException e) {
+            Log.e(TAG, "Failed to encrypt with secret key.", e);
+            return null;
+        }
+        byte[] esalt = Cryptor.computeHkdf(salt, ESALT_IV, AES_CTR_IV_SIZE);
+        if (esalt == null) {
+            Log.e(TAG, "Failed to generate salt.");
+            return null;
+        }
+        try {
+            cipher.init(Cipher.ENCRYPT_MODE, secretKey, new IvParameterSpec(esalt));
+        } catch (InvalidKeyException | InvalidAlgorithmParameterException e) {
+            Log.e(TAG, "Failed to initialize cipher.", e);
+            return null;
+        }
+        try {
+            return cipher.doFinal(data);
+        } catch (IllegalBlockSizeException | BadPaddingException e) {
+            Log.e(TAG, "Failed to encrypt with secret key.", e);
+            return null;
+        }
+    }
+
+    @Nullable
+    @Override
+    public byte[] decrypt(byte[] encryptedData, byte[] salt, byte[] authenticityKey) {
+        if (authenticityKey.length != AUTHENTICITY_KEY_BYTE_SIZE) {
+            Log.w(TAG, "Illegal authenticity key size");
+            return null;
+        }
+
+        // Generates a 32 bytes encryption key from authenticity_key
+        byte[] encryptionKey = Cryptor.computeHkdf(authenticityKey, EK_IV, ENCRYPTION_KEY_SIZE);
+        if (encryptionKey == null) {
+            Log.e(TAG, "Failed to generate encryption key.");
+            return null;
+        }
+
+        // Decrypts the data using the encryption key
+        SecretKey secretKey = new SecretKeySpec(encryptionKey, ENCRYPT_ALGORITHM);
+        Cipher cipher;
+        try {
+            cipher = Cipher.getInstance(CIPHER_ALGORITHM);
+        } catch (NoSuchAlgorithmException | NoSuchPaddingException e) {
+            Log.e(TAG, "Failed to get cipher instance.", e);
+            return null;
+        }
+        byte[] esalt = Cryptor.computeHkdf(salt, ESALT_IV, AES_CTR_IV_SIZE);
+        if (esalt == null) {
+            return null;
+        }
+        try {
+            cipher.init(Cipher.DECRYPT_MODE, secretKey, new IvParameterSpec(esalt));
+        } catch (InvalidKeyException | InvalidAlgorithmParameterException e) {
+            Log.e(TAG, "Failed to initialize cipher.", e);
+            return null;
+        }
+
+        try {
+            return cipher.doFinal(encryptedData);
+        } catch (IllegalBlockSizeException | BadPaddingException e) {
+            Log.e(TAG, "Failed to decrypt bytes with secret key.", e);
+            return null;
+        }
+    }
+
+    /**
+     * Generates a digital signature for the data.
+     *
+     * @return signature {@code null} if failed to sign
+     */
+    @Nullable
+    @Override
+    public byte[] sign(byte[] data, byte[] salt) {
+        if (data == null) {
+            Log.e(TAG, "Not generate HMAC tag because of invalid data input.");
+            return null;
+        }
+
+        // Generates a 8 bytes HMAC tag
+        return Cryptor.computeHkdf(data, salt, HMAC_TAG_SIZE);
+    }
+
+    /**
+     * Generates a digital signature for the data.
+     * Uses KTAG_IV as salt value.
+     */
+    @Nullable
+    public byte[] sign(byte[] data) {
+        // Generates a 8 bytes HMAC tag
+        return sign(data, KTAG_IV);
+    }
+
+    @Override
+    public boolean verify(byte[] data, byte[] key, byte[] signature) {
+        return Arrays.equals(sign(data, key), signature);
+    }
+
+    /**
+     * Verifies the signature generated by data and key, with the original signed data. Uses
+     * KTAG_IV as salt value.
+     */
+    public boolean verify(byte[] data, byte[] signature) {
+        return verify(data, KTAG_IV, signature);
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/util/encryption/CryptorImpV1.java b/nearby/service/java/com/android/server/nearby/util/encryption/CryptorImpV1.java
new file mode 100644
index 0000000..15073fb
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/util/encryption/CryptorImpV1.java
@@ -0,0 +1,212 @@
+/*
+ * 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.nearby.util.encryption;
+
+import static com.android.server.nearby.NearbyService.TAG;
+
+import android.security.keystore.KeyProperties;
+import android.util.Log;
+
+import androidx.annotation.Nullable;
+
+import com.android.internal.annotations.VisibleForTesting;
+
+import java.security.InvalidAlgorithmParameterException;
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+import java.util.Arrays;
+
+import javax.crypto.BadPaddingException;
+import javax.crypto.Cipher;
+import javax.crypto.IllegalBlockSizeException;
+import javax.crypto.NoSuchPaddingException;
+import javax.crypto.SecretKey;
+import javax.crypto.spec.IvParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
+
+/**
+ * {@link android.nearby.BroadcastRequest#PRESENCE_VERSION_V1} for encryption and decryption.
+ */
+public class CryptorImpV1 extends Cryptor {
+
+    /**
+     * In the form of "algorithm/mode/padding". Must be the same across broadcast and scan devices.
+     */
+    private static final String CIPHER_ALGORITHM = "AES/CTR/NoPadding";
+
+    @VisibleForTesting
+    static final String ENCRYPT_ALGORITHM = KeyProperties.KEY_ALGORITHM_AES;
+
+    /** Length of encryption key required by AES/GCM encryption. */
+    private static final int ENCRYPTION_KEY_SIZE = 32;
+
+    /** Length of salt required by AES/GCM encryption. */
+    private static final int AES_CTR_IV_SIZE = 16;
+
+    /** Length HMAC tag */
+    public static final int HMAC_TAG_SIZE = 16;
+
+    // 3 16 byte arrays known by both the encryptor and decryptor.
+    private static final byte[] AK_IV =
+            new byte[] {12, -59, 19, 23, 96, 57, -59, 19, 117, -31, -116, -61, 86, -25, -33, -78};
+    private static final byte[] ASALT_IV =
+            new byte[] {111, 48, -83, -79, -10, -102, -16, 73, 43, 55, 102, -127, 58, -19, -113, 4};
+    private static final byte[] HK_IV =
+            new byte[] {12, -59, 19, 23, 96, 57, -59, 19, 117, -31, -116, -61, 86, -25, -33, -78};
+
+    // Lazily instantiated when {@link #getInstance()} is called.
+    @Nullable private static CryptorImpV1 sCryptor;
+
+    /** Returns an instance of CryptorImpV1. */
+    public static CryptorImpV1 getInstance() {
+        if (sCryptor == null) {
+            sCryptor = new CryptorImpV1();
+        }
+        return sCryptor;
+    }
+
+    private CryptorImpV1() {
+    }
+
+    @Nullable
+    @Override
+    public byte[] encrypt(byte[] data, byte[] salt, byte[] authenticityKey) {
+        if (authenticityKey.length != AUTHENTICITY_KEY_BYTE_SIZE) {
+            Log.w(TAG, "Illegal authenticity key size");
+            return null;
+        }
+
+        // Generates a 32 bytes encryption key from authenticity_key
+        byte[] encryptionKey = Cryptor.computeHkdf(authenticityKey, AK_IV, ENCRYPTION_KEY_SIZE);
+        if (encryptionKey == null) {
+            Log.e(TAG, "Failed to generate encryption key.");
+            return null;
+        }
+
+        // Encrypts the data using the encryption key
+        SecretKey secretKey = new SecretKeySpec(encryptionKey, ENCRYPT_ALGORITHM);
+        Cipher cipher;
+        try {
+            cipher = Cipher.getInstance(CIPHER_ALGORITHM);
+        } catch (NoSuchAlgorithmException | NoSuchPaddingException e) {
+            Log.e(TAG, "Failed to encrypt with secret key.", e);
+            return null;
+        }
+        byte[] asalt = Cryptor.computeHkdf(salt, ASALT_IV, AES_CTR_IV_SIZE);
+        if (asalt == null) {
+            Log.e(TAG, "Failed to generate salt.");
+            return null;
+        }
+        try {
+            cipher.init(Cipher.ENCRYPT_MODE, secretKey, new IvParameterSpec(asalt));
+        } catch (InvalidKeyException | InvalidAlgorithmParameterException e) {
+            Log.e(TAG, "Failed to initialize cipher.", e);
+            return null;
+        }
+        try {
+            return cipher.doFinal(data);
+        } catch (IllegalBlockSizeException | BadPaddingException e) {
+            Log.e(TAG, "Failed to encrypt with secret key.", e);
+            return null;
+        }
+    }
+
+    @Nullable
+    @Override
+    public byte[] decrypt(byte[] encryptedData, byte[] salt, byte[] authenticityKey) {
+        if (authenticityKey.length != AUTHENTICITY_KEY_BYTE_SIZE) {
+            Log.w(TAG, "Illegal authenticity key size");
+            return null;
+        }
+
+        // Generates a 32 bytes encryption key from authenticity_key
+        byte[] encryptionKey = Cryptor.computeHkdf(authenticityKey, AK_IV, ENCRYPTION_KEY_SIZE);
+        if (encryptionKey == null) {
+            Log.e(TAG, "Failed to generate encryption key.");
+            return null;
+        }
+
+        // Decrypts the data using the encryption key
+        SecretKey secretKey = new SecretKeySpec(encryptionKey, ENCRYPT_ALGORITHM);
+        Cipher cipher;
+        try {
+            cipher = Cipher.getInstance(CIPHER_ALGORITHM);
+        } catch (NoSuchAlgorithmException | NoSuchPaddingException e) {
+            Log.e(TAG, "Failed to get cipher instance.", e);
+            return null;
+        }
+        byte[] asalt = Cryptor.computeHkdf(salt, ASALT_IV, AES_CTR_IV_SIZE);
+        if (asalt == null) {
+            return null;
+        }
+        try {
+            cipher.init(Cipher.DECRYPT_MODE, secretKey, new IvParameterSpec(asalt));
+        } catch (InvalidKeyException | InvalidAlgorithmParameterException e) {
+            Log.e(TAG, "Failed to initialize cipher.", e);
+            return null;
+        }
+
+        try {
+            return cipher.doFinal(encryptedData);
+        } catch (IllegalBlockSizeException | BadPaddingException e) {
+            Log.e(TAG, "Failed to decrypt bytes with secret key.", e);
+            return null;
+        }
+    }
+
+    @Override
+    @Nullable
+    public byte[] sign(byte[] data, byte[] key) {
+        return generateHmacTag(data, key);
+    }
+
+    @Override
+    public int getSignatureLength() {
+        return HMAC_TAG_SIZE;
+    }
+
+    @Override
+    public boolean verify(byte[] data, byte[] key, byte[] signature) {
+        return Arrays.equals(sign(data, key), signature);
+    }
+
+    /** Generates a 16 bytes HMAC tag. This is used for decryptor to verify if the computed HMAC tag
+     * is equal to HMAC tag in advertisement to see data integrity. */
+    @Nullable
+    @VisibleForTesting
+    byte[] generateHmacTag(byte[] data, byte[] authenticityKey) {
+        if (data == null || authenticityKey == null) {
+            Log.e(TAG, "Not generate HMAC tag because of invalid data input.");
+            return null;
+        }
+
+        if (authenticityKey.length != AUTHENTICITY_KEY_BYTE_SIZE) {
+            Log.e(TAG, "Illegal authenticity key size");
+            return null;
+        }
+
+        // Generates a 32 bytes HMAC key from authenticity_key
+        byte[] hmacKey = Cryptor.computeHkdf(authenticityKey, HK_IV, AES_CTR_IV_SIZE);
+        if (hmacKey == null) {
+            Log.e(TAG, "Failed to generate HMAC key.");
+            return null;
+        }
+
+        // Generates a 16 bytes HMAC tag from authenticity_key
+        return Cryptor.computeHkdf(data, hmacKey, HMAC_TAG_SIZE);
+    }
+}
diff --git a/nearby/service/lint-baseline.xml b/nearby/service/lint-baseline.xml
new file mode 100644
index 0000000..a4761ab
--- /dev/null
+++ b/nearby/service/lint-baseline.xml
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<issues format="6" by="lint 8.0.0-dev" type="baseline" dependencies="true" variant="all" version="8.0.0-dev">
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `android.hardware.location.ContextHubManager#createClient`"
+        errorLine1="                        mContextHubClient = mManager.createClient(mContext, mQueriedContextHub,"
+        errorLine2="                                                     ~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/nearby/service/java/com/android/server/nearby/provider/ChreCommunication.java"
+            line="263"
+            column="54"/>
+    </issue>
+
+</issues>
\ No newline at end of file
diff --git a/nearby/service/proto/src/presence/blefilter.proto b/nearby/service/proto/src/presence/blefilter.proto
index 9f75d34..e1bf455 100644
--- a/nearby/service/proto/src/presence/blefilter.proto
+++ b/nearby/service/proto/src/presence/blefilter.proto
@@ -47,6 +47,7 @@
   optional bytes metadata_encryption_key_tag = 2;
 }
 
+// Public credential returned in BleFilterResult.
 message PublicCredential {
   optional bytes secret_id = 1;
   optional bytes authenticity_key = 2;
@@ -55,6 +56,23 @@
   optional bytes encrypted_metadata_tag = 5;
 }
 
+message DataElement {
+  enum ElementType {
+    DE_NONE = 0;
+    DE_FAST_PAIR_ACCOUNT_KEY = 9;
+    DE_CONNECTION_STATUS = 10;
+    DE_BATTERY_STATUS = 11;
+    // Reserves 128 Test DEs.
+    DE_TEST_BEGIN = 2147483520;  // INT_MAX - 127
+    DE_TEST_END = 2147483647;    // INT_MAX
+  }
+
+  optional int32 key = 1;
+  optional bytes value = 2;
+  optional uint32 value_length = 3;
+}
+
+// A single filter used to filter BLE events.
 message BleFilter {
   optional uint32 id = 1;  // Required, unique id of this filter.
   // Maximum delay to notify the client after an event occurs.
@@ -71,7 +89,9 @@
   // the period of latency defined above.
   optional float distance_m = 7;
   // Used to verify the list of trusted devices.
-  repeated PublicateCertificate certficate = 8;
+  repeated PublicateCertificate certificate = 8;
+  // Data Elements for extended properties.
+  repeated DataElement data_element = 9;
 }
 
 message BleFilters {
@@ -80,14 +100,33 @@
 
 // FilterResult is returned to host when a BLE event matches a Filter.
 message BleFilterResult {
+  enum ResultType {
+    RESULT_NONE = 0;
+    RESULT_PRESENCE = 1;
+    RESULT_FAST_PAIR = 2;
+  }
+
   optional uint32 id = 1;  // id of the matched Filter.
-  optional uint32 tx_power = 2;
-  optional uint32 rssi = 3;
+  optional int32 tx_power = 2;
+  optional int32 rssi = 3;
   optional uint32 intent = 4;
   optional bytes bluetooth_address = 5;
   optional PublicCredential public_credential = 6;
+  repeated DataElement data_element = 7;
+  optional bytes ble_service_data = 8;
+  optional ResultType result_type = 9;
 }
 
 message BleFilterResults {
   repeated BleFilterResult result = 1;
 }
+
+message BleConfig {
+  // True to start BLE scan. Otherwise, stop BLE scan.
+  optional bool start_scan = 1;
+  // True when screen is turned on. Otherwise, set to false when screen is
+  // turned off.
+  optional bool screen_on = 2;
+  // Fast Pair cache expires after this time period.
+  optional uint64 fast_pair_cache_expire_time_sec = 3;
+}
diff --git a/nearby/tests/cts/fastpair/Android.bp b/nearby/tests/cts/fastpair/Android.bp
index 0410cd5..66a1ffe 100644
--- a/nearby/tests/cts/fastpair/Android.bp
+++ b/nearby/tests/cts/fastpair/Android.bp
@@ -26,7 +26,7 @@
         "bluetooth-test-util-lib",
         "compatibility-device-util-axt",
         "ctstestrunner-axt",
-        "truth-prebuilt",
+        "truth",
     ],
     libs: [
         "android.test.base",
@@ -41,7 +41,6 @@
         "mts-tethering",
     ],
     certificate: "platform",
-    platform_apis: true,
     sdk_version: "module_current",
     min_sdk_version: "30",
     target_sdk_version: "32",
diff --git a/nearby/tests/cts/fastpair/src/android/nearby/cts/CredentialElementTest.java b/nearby/tests/cts/fastpair/src/android/nearby/cts/CredentialElementTest.java
index aacb6d8..a2da967 100644
--- a/nearby/tests/cts/fastpair/src/android/nearby/cts/CredentialElementTest.java
+++ b/nearby/tests/cts/fastpair/src/android/nearby/cts/CredentialElementTest.java
@@ -42,7 +42,6 @@
     @SdkSuppress(minSdkVersion = 32, codeName = "T")
     public void testBuilder() {
         CredentialElement element = new CredentialElement(KEY, VALUE);
-
         assertThat(element.getKey()).isEqualTo(KEY);
         assertThat(Arrays.equals(element.getValue(), VALUE)).isTrue();
     }
@@ -58,9 +57,31 @@
         CredentialElement elementFromParcel = element.CREATOR.createFromParcel(
                 parcel);
         parcel.recycle();
-
         assertThat(elementFromParcel.getKey()).isEqualTo(KEY);
         assertThat(Arrays.equals(elementFromParcel.getValue(), VALUE)).isTrue();
     }
 
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void describeContents() {
+        CredentialElement element = new CredentialElement(KEY, VALUE);
+        assertThat(element.describeContents()).isEqualTo(0);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testEqual() {
+        CredentialElement element1 = new CredentialElement(KEY, VALUE);
+        CredentialElement element2 = new CredentialElement(KEY, VALUE);
+        assertThat(element1.equals(element2)).isTrue();
+        assertThat(element1.hashCode()).isEqualTo(element2.hashCode());
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testCreatorNewArray() {
+        CredentialElement [] elements =
+                CredentialElement.CREATOR.newArray(2);
+        assertThat(elements.length).isEqualTo(2);
+    }
 }
diff --git a/nearby/tests/cts/fastpair/src/android/nearby/cts/DataElementTest.java b/nearby/tests/cts/fastpair/src/android/nearby/cts/DataElementTest.java
index 3654d0d..84814ae 100644
--- a/nearby/tests/cts/fastpair/src/android/nearby/cts/DataElementTest.java
+++ b/nearby/tests/cts/fastpair/src/android/nearby/cts/DataElementTest.java
@@ -16,6 +16,13 @@
 
 package android.nearby.cts;
 
+import static android.nearby.DataElement.DataType.PRIVATE_IDENTITY;
+import static android.nearby.DataElement.DataType.PROVISIONED_IDENTITY;
+import static android.nearby.DataElement.DataType.PUBLIC_IDENTITY;
+import static android.nearby.DataElement.DataType.SALT;
+import static android.nearby.DataElement.DataType.TRUSTED_IDENTITY;
+import static android.nearby.DataElement.DataType.TX_POWER;
+
 import static com.google.common.truth.Truth.assertThat;
 
 import android.nearby.DataElement;
@@ -31,7 +38,6 @@
 
 import java.util.Arrays;
 
-
 @RunWith(AndroidJUnit4.class)
 @RequiresApi(Build.VERSION_CODES.TIRAMISU)
 public class DataElementTest {
@@ -63,4 +69,59 @@
         assertThat(elementFromParcel.getKey()).isEqualTo(KEY);
         assertThat(Arrays.equals(elementFromParcel.getValue(), VALUE)).isTrue();
     }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void describeContents() {
+        DataElement dataElement = new DataElement(KEY, VALUE);
+        assertThat(dataElement.describeContents()).isEqualTo(0);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testCreatorNewArray() {
+        DataElement[] elements =
+                DataElement.CREATOR.newArray(2);
+        assertThat(elements.length).isEqualTo(2);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testEquals() {
+        DataElement dataElement = new DataElement(KEY, VALUE);
+        DataElement dataElement2 = new DataElement(KEY, VALUE);
+
+        assertThat(dataElement.equals(dataElement2)).isTrue();
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testIsIdentity() {
+        DataElement privateIdentity = new DataElement(PRIVATE_IDENTITY, new byte[]{1, 2, 3});
+        DataElement trustedIdentity = new DataElement(TRUSTED_IDENTITY, new byte[]{1, 2, 3});
+        DataElement publicIdentity = new DataElement(PUBLIC_IDENTITY, new byte[]{1, 2, 3});
+        DataElement provisionedIdentity =
+                new DataElement(PROVISIONED_IDENTITY, new byte[]{1, 2, 3});
+
+        DataElement salt = new DataElement(SALT, new byte[]{1, 2, 3});
+        DataElement txPower = new DataElement(TX_POWER, new byte[]{1, 2, 3});
+
+        assertThat(privateIdentity.isIdentityDataType()).isTrue();
+        assertThat(trustedIdentity.isIdentityDataType()).isTrue();
+        assertThat(publicIdentity.isIdentityDataType()).isTrue();
+        assertThat(provisionedIdentity.isIdentityDataType()).isTrue();
+        assertThat(salt.isIdentityDataType()).isFalse();
+        assertThat(txPower.isIdentityDataType()).isFalse();
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_notEquals() {
+        DataElement dataElement = new DataElement(KEY, VALUE);
+        DataElement dataElement2 = new DataElement(KEY, new byte[]{1, 2, 1, 1});
+        DataElement dataElement3 = new DataElement(6, VALUE);
+
+        assertThat(dataElement.equals(dataElement2)).isFalse();
+        assertThat(dataElement.equals(dataElement3)).isFalse();
+    }
 }
diff --git a/nearby/tests/cts/fastpair/src/android/nearby/cts/NearbyDeviceTest.java b/nearby/tests/cts/fastpair/src/android/nearby/cts/NearbyDeviceTest.java
index f37800a..8ca5a94 100644
--- a/nearby/tests/cts/fastpair/src/android/nearby/cts/NearbyDeviceTest.java
+++ b/nearby/tests/cts/fastpair/src/android/nearby/cts/NearbyDeviceTest.java
@@ -16,6 +16,8 @@
 
 package android.nearby.cts;
 
+import static android.nearby.NearbyDevice.Medium.BLE;
+
 import android.annotation.TargetApi;
 import android.nearby.FastPairDevice;
 import android.nearby.NearbyDevice;
@@ -34,13 +36,18 @@
 @RequiresApi(Build.VERSION_CODES.TIRAMISU)
 @TargetApi(Build.VERSION_CODES.TIRAMISU)
 public class NearbyDeviceTest {
+    private static final String NAME = "NearbyDevice";
+    private static final String MODEL_ID = "112233";
+    private static final int TX_POWER = -10;
+    private static final int RSSI = -60;
+    private static final String BLUETOOTH_ADDRESS = "00:11:22:33:FF:EE";
+    private static final byte[] SCAN_DATA = new byte[] {1, 2, 3, 4};
 
     @Test
     @SdkSuppress(minSdkVersion = 32, codeName = "T")
     public void test_isValidMedium() {
         assertThat(NearbyDevice.isValidMedium(1)).isTrue();
         assertThat(NearbyDevice.isValidMedium(2)).isTrue();
-
         assertThat(NearbyDevice.isValidMedium(0)).isFalse();
         assertThat(NearbyDevice.isValidMedium(3)).isFalse();
     }
@@ -49,11 +56,55 @@
     @SdkSuppress(minSdkVersion = 32, codeName = "T")
     public void test_getMedium_fromChild() {
         FastPairDevice fastPairDevice = new FastPairDevice.Builder()
-                .addMedium(NearbyDevice.Medium.BLE)
-                .setRssi(-60)
+                .addMedium(BLE)
+                .setRssi(RSSI)
                 .build();
 
         assertThat(fastPairDevice.getMediums()).contains(1);
-        assertThat(fastPairDevice.getRssi()).isEqualTo(-60);
+        assertThat(fastPairDevice.getRssi()).isEqualTo(RSSI);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testEqual() {
+        FastPairDevice fastPairDevice1 = new FastPairDevice.Builder()
+                .setModelId(MODEL_ID)
+                .setTxPower(TX_POWER)
+                .setBluetoothAddress(BLUETOOTH_ADDRESS)
+                .setData(SCAN_DATA)
+                .setRssi(RSSI)
+                .addMedium(BLE)
+                .setName(NAME)
+                .build();
+        FastPairDevice fastPairDevice2 = new FastPairDevice.Builder()
+                .setModelId(MODEL_ID)
+                .setTxPower(TX_POWER)
+                .setBluetoothAddress(BLUETOOTH_ADDRESS)
+                .setData(SCAN_DATA)
+                .setRssi(RSSI)
+                .addMedium(BLE)
+                .setName(NAME)
+                .build();
+
+        assertThat(fastPairDevice1.equals(fastPairDevice1)).isTrue();
+        assertThat(fastPairDevice1.equals(fastPairDevice2)).isTrue();
+        assertThat(fastPairDevice1.equals(null)).isFalse();
+        assertThat(fastPairDevice1.hashCode()).isEqualTo(fastPairDevice2.hashCode());
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testToString() {
+        FastPairDevice fastPairDevice1 = new FastPairDevice.Builder()
+                .addMedium(BLE)
+                .setRssi(RSSI)
+                .setModelId(MODEL_ID)
+                .setTxPower(TX_POWER)
+                .setBluetoothAddress(BLUETOOTH_ADDRESS)
+                .build();
+
+        assertThat(fastPairDevice1.toString())
+                .isEqualTo("FastPairDevice [medium={BLE} rssi=-60 "
+                        + "txPower=-10 modelId=112233 bluetoothAddress=00:11:22:33:FF:EE]");
     }
 }
diff --git a/nearby/tests/cts/fastpair/src/android/nearby/cts/NearbyManagerTest.java b/nearby/tests/cts/fastpair/src/android/nearby/cts/NearbyManagerTest.java
index 7696a61..bc9691d 100644
--- a/nearby/tests/cts/fastpair/src/android/nearby/cts/NearbyManagerTest.java
+++ b/nearby/tests/cts/fastpair/src/android/nearby/cts/NearbyManagerTest.java
@@ -20,7 +20,7 @@
 import static android.Manifest.permission.READ_DEVICE_CONFIG;
 import static android.Manifest.permission.WRITE_DEVICE_CONFIG;
 import static android.nearby.PresenceCredential.IDENTITY_TYPE_PRIVATE;
-import static android.provider.DeviceConfig.NAMESPACE_TETHERING;
+import static android.nearby.ScanCallback.ERROR_UNSUPPORTED;
 
 import static com.google.common.truth.Truth.assertThat;
 
@@ -35,7 +35,9 @@
 import android.nearby.BroadcastRequest;
 import android.nearby.NearbyDevice;
 import android.nearby.NearbyManager;
+import android.nearby.OffloadCapability;
 import android.nearby.PresenceBroadcastRequest;
+import android.nearby.PresenceDevice;
 import android.nearby.PrivateCredential;
 import android.nearby.ScanCallback;
 import android.nearby.ScanRequest;
@@ -48,6 +50,8 @@
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.filters.SdkSuppress;
 
+import com.android.modules.utils.build.SdkLevel;
+
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -57,6 +61,7 @@
 import java.util.concurrent.Executor;
 import java.util.concurrent.Executors;
 import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
 
 /**
  * TODO(b/215435939) This class doesn't include any logic yet. Because SELinux denies access to
@@ -66,7 +71,7 @@
 @RequiresApi(Build.VERSION_CODES.TIRAMISU)
 public class NearbyManagerTest {
     private static final byte[] SALT = new byte[]{1, 2};
-    private static final byte[] SECRETE_ID = new byte[]{1, 2, 3, 4};
+    private static final byte[] SECRET_ID = new byte[]{1, 2, 3, 4};
     private static final byte[] META_DATA_ENCRYPTION_KEY = new byte[14];
     private static final byte[] AUTHENTICITY_KEY = new byte[]{0, 1, 1, 1};
     private static final String DEVICE_NAME = "test_device";
@@ -82,6 +87,9 @@
             .setScanMode(ScanRequest.SCAN_MODE_LOW_LATENCY)
             .setBleEnabled(true)
             .build();
+    private PresenceDevice.Builder mBuilder =
+            new PresenceDevice.Builder("deviceId", SALT, SECRET_ID, META_DATA_ENCRYPTION_KEY);
+
     private  ScanCallback mScanCallback = new ScanCallback() {
         @Override
         public void onDiscovered(@NonNull NearbyDevice device) {
@@ -94,14 +102,21 @@
         @Override
         public void onLost(@NonNull NearbyDevice device) {
         }
+
+        @Override
+        public void onError(int errorCode) {
+        }
     };
+
     private static final Executor EXECUTOR = Executors.newSingleThreadExecutor();
 
     @Before
     public void setUp() {
         mUiAutomation.adoptShellPermissionIdentity(READ_DEVICE_CONFIG, WRITE_DEVICE_CONFIG,
                 BLUETOOTH_PRIVILEGED);
-        DeviceConfig.setProperty(NAMESPACE_TETHERING,
+        String nameSpace = SdkLevel.isAtLeastU() ? DeviceConfig.NAMESPACE_NEARBY
+                : DeviceConfig.NAMESPACE_TETHERING;
+        DeviceConfig.setProperty(nameSpace,
                 "nearby_enable_presence_broadcast_legacy",
                 "true", false);
 
@@ -137,7 +152,7 @@
     @Test
     @SdkSuppress(minSdkVersion = 32, codeName = "T")
     public void testStartStopBroadcast() throws InterruptedException {
-        PrivateCredential credential = new PrivateCredential.Builder(SECRETE_ID, AUTHENTICITY_KEY,
+        PrivateCredential credential = new PrivateCredential.Builder(SECRET_ID, AUTHENTICITY_KEY,
                 META_DATA_ENCRYPTION_KEY, DEVICE_NAME)
                 .setIdentityType(IDENTITY_TYPE_PRIVATE)
                 .build();
@@ -158,6 +173,22 @@
         mNearbyManager.stopBroadcast(callback);
     }
 
+    @Test
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+    public void queryOffloadScanSupport() {
+        OffloadCallback callback = new OffloadCallback();
+        mNearbyManager.queryOffloadCapability(EXECUTOR, callback);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+    public void testAllCallbackMethodsExits() {
+        mScanCallback.onDiscovered(mBuilder.setRssi(-10).build());
+        mScanCallback.onUpdated(mBuilder.setRssi(-5).build());
+        mScanCallback.onLost(mBuilder.setRssi(-8).build());
+        mScanCallback.onError(ERROR_UNSUPPORTED);
+    }
+
     private void enableBluetooth() {
         BluetoothManager manager = mContext.getSystemService(BluetoothManager.class);
         BluetoothAdapter bluetoothAdapter = manager.getAdapter();
@@ -165,4 +196,11 @@
             assertThat(BTAdapterUtils.enableAdapter(bluetoothAdapter, mContext)).isTrue();
         }
     }
+
+    private static class OffloadCallback implements Consumer<OffloadCapability> {
+        @Override
+        public void accept(OffloadCapability aBoolean) {
+            // no-op for now
+        }
+    }
 }
diff --git a/nearby/tests/cts/fastpair/src/android/nearby/cts/OffloadCapabilityTest.java b/nearby/tests/cts/fastpair/src/android/nearby/cts/OffloadCapabilityTest.java
new file mode 100644
index 0000000..a745c7d
--- /dev/null
+++ b/nearby/tests/cts/fastpair/src/android/nearby/cts/OffloadCapabilityTest.java
@@ -0,0 +1,76 @@
+/*
+ * 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.nearby.cts;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.nearby.OffloadCapability;
+import android.os.Build;
+import android.os.Parcel;
+
+import androidx.annotation.RequiresApi;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+public class OffloadCapabilityTest {
+    private static final long VERSION = 123456;
+
+    @Test
+    public void testDefault() {
+        OffloadCapability offloadCapability = new OffloadCapability.Builder().build();
+
+        assertThat(offloadCapability.isFastPairSupported()).isFalse();
+        assertThat(offloadCapability.isNearbyShareSupported()).isFalse();
+        assertThat(offloadCapability.getVersion()).isEqualTo(0);
+    }
+
+    @Test
+    public void testBuilder() {
+        OffloadCapability offloadCapability = new OffloadCapability.Builder()
+                .setFastPairSupported(true)
+                .setNearbyShareSupported(true)
+                .setVersion(VERSION)
+                .build();
+
+        assertThat(offloadCapability.isFastPairSupported()).isTrue();
+        assertThat(offloadCapability.isNearbyShareSupported()).isTrue();
+        assertThat(offloadCapability.getVersion()).isEqualTo(VERSION);
+    }
+
+    @Test
+    public void testWriteParcel() {
+        OffloadCapability offloadCapability = new OffloadCapability.Builder()
+                .setFastPairSupported(true)
+                .setNearbyShareSupported(false)
+                .setVersion(VERSION)
+                .build();
+
+        Parcel parcel = Parcel.obtain();
+        offloadCapability.writeToParcel(parcel, 0);
+        parcel.setDataPosition(0);
+        OffloadCapability capability = OffloadCapability.CREATOR.createFromParcel(parcel);
+        parcel.recycle();
+
+        assertThat(capability.isFastPairSupported()).isTrue();
+        assertThat(capability.isNearbyShareSupported()).isFalse();
+        assertThat(capability.getVersion()).isEqualTo(VERSION);
+    }
+}
diff --git a/nearby/tests/cts/fastpair/src/android/nearby/cts/PresenceBroadcastRequestTest.java b/nearby/tests/cts/fastpair/src/android/nearby/cts/PresenceBroadcastRequestTest.java
index eaa5ca1..71be889 100644
--- a/nearby/tests/cts/fastpair/src/android/nearby/cts/PresenceBroadcastRequestTest.java
+++ b/nearby/tests/cts/fastpair/src/android/nearby/cts/PresenceBroadcastRequestTest.java
@@ -114,4 +114,18 @@
         assertThat(parcelRequest.getType()).isEqualTo(BROADCAST_TYPE_NEARBY_PRESENCE);
 
     }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void describeContents() {
+        PresenceBroadcastRequest broadcastRequest = mBuilder.build();
+        assertThat(broadcastRequest.describeContents()).isEqualTo(0);
+    }
+
+    @Test
+    public void testCreatorNewArray() {
+        PresenceBroadcastRequest[] presenceBroadcastRequests =
+                PresenceBroadcastRequest.CREATOR.newArray(2);
+        assertThat(presenceBroadcastRequests.length).isEqualTo(2);
+    }
 }
diff --git a/nearby/tests/cts/fastpair/src/android/nearby/cts/PresenceDeviceTest.java b/nearby/tests/cts/fastpair/src/android/nearby/cts/PresenceDeviceTest.java
index 94f8fe7..ea1de6b 100644
--- a/nearby/tests/cts/fastpair/src/android/nearby/cts/PresenceDeviceTest.java
+++ b/nearby/tests/cts/fastpair/src/android/nearby/cts/PresenceDeviceTest.java
@@ -104,4 +104,24 @@
         assertThat(parcelDevice.getMediums()).containsExactly(MEDIUM);
         assertThat(parcelDevice.getName()).isEqualTo(DEVICE_NAME);
     }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void describeContents() {
+        PresenceDevice device =
+                new PresenceDevice.Builder(DEVICE_ID, SALT, SECRET_ID, ENCRYPTED_IDENTITY)
+                        .addExtendedProperty(new DataElement(KEY, VALUE))
+                        .setRssi(RSSI)
+                        .addMedium(MEDIUM)
+                        .setName(DEVICE_NAME)
+                        .build();
+        assertThat(device.describeContents()).isEqualTo(0);
+    }
+
+    @Test
+    public void testCreatorNewArray() {
+        PresenceDevice[] devices =
+                PresenceDevice.CREATOR.newArray(2);
+        assertThat(devices.length).isEqualTo(2);
+    }
 }
diff --git a/nearby/tests/cts/fastpair/src/android/nearby/cts/PresenceScanFilterTest.java b/nearby/tests/cts/fastpair/src/android/nearby/cts/PresenceScanFilterTest.java
index cecdfd2..821f2d0 100644
--- a/nearby/tests/cts/fastpair/src/android/nearby/cts/PresenceScanFilterTest.java
+++ b/nearby/tests/cts/fastpair/src/android/nearby/cts/PresenceScanFilterTest.java
@@ -51,7 +51,6 @@
     private static final int KEY = 3;
     private static final byte[] VALUE = new byte[]{1, 1, 1, 1};
 
-
     private PublicCredential mPublicCredential =
             new PublicCredential.Builder(SECRETE_ID, AUTHENTICITY_KEY, PUBLIC_KEY,
                     ENCRYPTED_METADATA, METADATA_ENCRYPTION_KEY_TAG)
@@ -90,5 +89,21 @@
         assertThat(parcelFilter.getType()).isEqualTo(ScanRequest.SCAN_TYPE_NEARBY_PRESENCE);
         assertThat(parcelFilter.getMaxPathLoss()).isEqualTo(RSSI);
         assertThat(parcelFilter.getPresenceActions()).containsExactly(ACTION);
+        assertThat(parcelFilter.getExtendedProperties().get(0).getKey()).isEqualTo(KEY);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void describeContents() {
+        PresenceScanFilter filter = mBuilder.build();
+        assertThat(filter.describeContents()).isEqualTo(0);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testCreatorNewArray() {
+        PresenceScanFilter[] filters =
+                PresenceScanFilter.CREATOR.newArray(2);
+        assertThat(filters.length).isEqualTo(2);
     }
 }
diff --git a/nearby/tests/cts/fastpair/src/android/nearby/cts/PrivateCredentialTest.java b/nearby/tests/cts/fastpair/src/android/nearby/cts/PrivateCredentialTest.java
index f05f65f..fa8c954 100644
--- a/nearby/tests/cts/fastpair/src/android/nearby/cts/PrivateCredentialTest.java
+++ b/nearby/tests/cts/fastpair/src/android/nearby/cts/PrivateCredentialTest.java
@@ -99,4 +99,19 @@
         assertThat(credentialElement.getKey()).isEqualTo(KEY);
         assertThat(Arrays.equals(credentialElement.getValue(), VALUE)).isTrue();
     }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 33, codeName = "T")
+    public void describeContents() {
+        PrivateCredential credential = mBuilder.build();
+        assertThat(credential.describeContents()).isEqualTo(0);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 33, codeName = "T")
+    public void testCreatorNewArray() {
+        PrivateCredential[]  credentials =
+                PrivateCredential.CREATOR.newArray(2);
+        assertThat(credentials.length).isEqualTo(2);
+    }
 }
diff --git a/nearby/tests/cts/fastpair/src/android/nearby/cts/PublicCredentialTest.java b/nearby/tests/cts/fastpair/src/android/nearby/cts/PublicCredentialTest.java
index 11bbacc..774e897 100644
--- a/nearby/tests/cts/fastpair/src/android/nearby/cts/PublicCredentialTest.java
+++ b/nearby/tests/cts/fastpair/src/android/nearby/cts/PublicCredentialTest.java
@@ -135,6 +135,7 @@
                         .setIdentityType(IDENTITY_TYPE_PRIVATE)
                         .build();
         assertThat(credentialOne.equals((Object) credentialTwo)).isTrue();
+        assertThat(credentialOne.equals(null)).isFalse();
     }
 
     @Test
@@ -161,4 +162,19 @@
                         .build();
         assertThat(credentialOne.equals((Object) credentialTwo)).isFalse();
     }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void describeContents() {
+        PublicCredential credential = mBuilder.build();
+        assertThat(credential.describeContents()).isEqualTo(0);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testCreatorNewArray() {
+        PublicCredential[] credentials  =
+        PublicCredential.CREATOR.newArray(2);
+        assertThat(credentials.length).isEqualTo(2);
+    }
 }
diff --git a/nearby/tests/cts/fastpair/src/android/nearby/cts/ScanRequestTest.java b/nearby/tests/cts/fastpair/src/android/nearby/cts/ScanRequestTest.java
index 21f3d28..5ad52c2 100644
--- a/nearby/tests/cts/fastpair/src/android/nearby/cts/ScanRequestTest.java
+++ b/nearby/tests/cts/fastpair/src/android/nearby/cts/ScanRequestTest.java
@@ -30,7 +30,6 @@
 import android.nearby.PublicCredential;
 import android.nearby.ScanRequest;
 import android.os.Build;
-import android.os.WorkSource;
 
 import androidx.annotation.RequiresApi;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
@@ -43,12 +42,10 @@
 @RequiresApi(Build.VERSION_CODES.TIRAMISU)
 public class ScanRequestTest {
 
-    private static final int UID = 1001;
-    private static final String APP_NAME = "android.nearby.tests";
     private static final int RSSI = -40;
 
     @Test
-    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    @SdkSuppress(minSdkVersion = 33, codeName = "T")
     public void testScanType() {
         ScanRequest request = new ScanRequest.Builder()
                 .setScanType(SCAN_TYPE_NEARBY_PRESENCE)
@@ -59,13 +56,13 @@
 
     // Valid scan type must be set to one of ScanRequest#SCAN_TYPE_
     @Test(expected = IllegalStateException.class)
-    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    @SdkSuppress(minSdkVersion = 33, codeName = "T")
     public void testScanType_notSet_throwsException() {
         new ScanRequest.Builder().setScanMode(SCAN_MODE_BALANCED).build();
     }
 
     @Test
-    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    @SdkSuppress(minSdkVersion = 33, codeName = "T")
     public void testScanMode_defaultLowPower() {
         ScanRequest request = new ScanRequest.Builder()
                 .setScanType(SCAN_TYPE_FAST_PAIR)
@@ -76,7 +73,7 @@
 
     /** Verify setting work source with null value in the scan request is allowed */
     @Test
-    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    @SdkSuppress(minSdkVersion = 33, codeName = "T")
     public void testSetWorkSource_nullValue() {
         ScanRequest request = new ScanRequest.Builder()
                 .setScanType(SCAN_TYPE_FAST_PAIR)
@@ -87,39 +84,9 @@
         assertThat(request.getWorkSource().isEmpty()).isTrue();
     }
 
-    /** Verify toString returns expected string. */
     @Test
-    @SdkSuppress(minSdkVersion = 32, codeName = "T")
-    public void testToString() {
-        WorkSource workSource = getWorkSource();
-        ScanRequest request = new ScanRequest.Builder()
-                .setScanType(SCAN_TYPE_FAST_PAIR)
-                .setScanMode(SCAN_MODE_BALANCED)
-                .setBleEnabled(true)
-                .setWorkSource(workSource)
-                .build();
-
-        assertThat(request.toString()).isEqualTo(
-                "Request[scanType=1, scanMode=SCAN_MODE_BALANCED, "
-                        + "enableBle=true, workSource=WorkSource{" + UID + " " + APP_NAME
-                        + "}, scanFilters=[]]");
-    }
-
-    /** Verify toString works correctly with null WorkSource. */
-    @Test
-    @SdkSuppress(minSdkVersion = 32, codeName = "T")
-    public void testToString_nullWorkSource() {
-        ScanRequest request = new ScanRequest.Builder().setScanType(
-                SCAN_TYPE_FAST_PAIR).setWorkSource(null).build();
-
-        assertThat(request.toString()).isEqualTo("Request[scanType=1, "
-                + "scanMode=SCAN_MODE_LOW_POWER, enableBle=true, workSource=WorkSource{}, "
-                + "scanFilters=[]]");
-    }
-
-    @Test
-    @SdkSuppress(minSdkVersion = 32, codeName = "T")
-    public void testisEnableBle_defaultTrue() {
+    @SdkSuppress(minSdkVersion = 33, codeName = "T")
+    public void testIsEnableBle_defaultTrue() {
         ScanRequest request = new ScanRequest.Builder()
                 .setScanType(SCAN_TYPE_FAST_PAIR)
                 .build();
@@ -128,7 +95,28 @@
     }
 
     @Test
-    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+    public void testIsOffloadOnly_defaultFalse() {
+        ScanRequest request = new ScanRequest.Builder()
+                .setScanType(SCAN_TYPE_FAST_PAIR)
+                .build();
+
+        assertThat(request.isOffloadOnly()).isFalse();
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+    public void testSetOffloadOnly_isOffloadOnlyTrue() {
+        ScanRequest request = new ScanRequest.Builder()
+                .setScanType(SCAN_TYPE_NEARBY_PRESENCE)
+                .setOffloadOnly(true)
+                .build();
+
+        assertThat(request.isOffloadOnly()).isTrue();
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 33, codeName = "T")
     public void test_isValidScanType() {
         assertThat(ScanRequest.isValidScanType(SCAN_TYPE_FAST_PAIR)).isTrue();
         assertThat(ScanRequest.isValidScanType(SCAN_TYPE_NEARBY_PRESENCE)).isTrue();
@@ -138,7 +126,7 @@
     }
 
     @Test
-    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    @SdkSuppress(minSdkVersion = 33, codeName = "T")
     public void test_isValidScanMode() {
         assertThat(ScanRequest.isValidScanMode(SCAN_MODE_LOW_LATENCY)).isTrue();
         assertThat(ScanRequest.isValidScanMode(SCAN_MODE_BALANCED)).isTrue();
@@ -150,7 +138,7 @@
     }
 
     @Test
-    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    @SdkSuppress(minSdkVersion = 33, codeName = "T")
     public void test_scanModeToString() {
         assertThat(ScanRequest.scanModeToString(2)).isEqualTo("SCAN_MODE_LOW_LATENCY");
         assertThat(ScanRequest.scanModeToString(1)).isEqualTo("SCAN_MODE_BALANCED");
@@ -162,7 +150,7 @@
     }
 
     @Test
-    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    @SdkSuppress(minSdkVersion = 33, codeName = "T")
     public void testScanFilter() {
         ScanRequest request = new ScanRequest.Builder().setScanType(
                 SCAN_TYPE_NEARBY_PRESENCE).addScanFilter(getPresenceScanFilter()).build();
@@ -171,6 +159,23 @@
         assertThat(request.getScanFilters().get(0).getMaxPathLoss()).isEqualTo(RSSI);
     }
 
+    @Test
+    @SdkSuppress(minSdkVersion = 33, codeName = "T")
+    public void describeContents() {
+        ScanRequest request = new ScanRequest.Builder()
+                .setScanType(SCAN_TYPE_FAST_PAIR)
+                .build();
+        assertThat(request.describeContents()).isEqualTo(0);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 33, codeName = "T")
+    public void testCreatorNewArray() {
+        ScanRequest[] requests =
+                ScanRequest.CREATOR.newArray(2);
+        assertThat(requests.length).isEqualTo(2);
+    }
+
     private static PresenceScanFilter getPresenceScanFilter() {
         final byte[] secretId = new byte[]{1, 2, 3, 4};
         final byte[] authenticityKey = new byte[]{0, 1, 1, 1};
@@ -190,8 +195,4 @@
                 .addPresenceAction(action)
                 .build();
     }
-
-    private static WorkSource getWorkSource() {
-        return new WorkSource(UID, APP_NAME);
-    }
 }
diff --git a/nearby/tests/integration/privileged/Android.bp b/nearby/tests/integration/privileged/Android.bp
index e3250f6..9b6e488 100644
--- a/nearby/tests/integration/privileged/Android.bp
+++ b/nearby/tests/integration/privileged/Android.bp
@@ -27,7 +27,7 @@
         "androidx.test.ext.junit",
         "androidx.test.rules",
         "junit",
-        "truth-prebuilt",
+        "truth",
     ],
     test_suites: ["device-tests"],
 }
diff --git a/nearby/tests/integration/privileged/src/android/nearby/integration/privileged/NearbyManagerTest.kt b/nearby/tests/integration/privileged/src/android/nearby/integration/privileged/NearbyManagerTest.kt
index 66bab23..506b4e2 100644
--- a/nearby/tests/integration/privileged/src/android/nearby/integration/privileged/NearbyManagerTest.kt
+++ b/nearby/tests/integration/privileged/src/android/nearby/integration/privileged/NearbyManagerTest.kt
@@ -63,6 +63,8 @@
             override fun onUpdated(device: NearbyDevice) {}
 
             override fun onLost(device: NearbyDevice) {}
+
+            override fun onError(errorCode: Int) {}
         }
 
         nearbyManager.startScan(scanRequest, /* executor */ { it.run() }, scanCallback)
diff --git a/nearby/tests/integration/untrusted/Android.bp b/nearby/tests/integration/untrusted/Android.bp
index 57499e4..75f765b 100644
--- a/nearby/tests/integration/untrusted/Android.bp
+++ b/nearby/tests/integration/untrusted/Android.bp
@@ -31,7 +31,7 @@
         "androidx.test.uiautomator_uiautomator",
         "junit",
         "kotlin-test",
-        "truth-prebuilt",
+        "truth",
     ],
     test_suites: ["device-tests"],
 }
diff --git a/nearby/tests/unit/Android.bp b/nearby/tests/unit/Android.bp
index 9b35452..112c751 100644
--- a/nearby/tests/unit/Android.bp
+++ b/nearby/tests/unit/Android.bp
@@ -42,7 +42,7 @@
         "mockito-target-extended-minus-junit4",
         "platform-test-annotations",
         "service-nearby-pre-jarjar",
-        "truth-prebuilt",
+        "truth",
         // "Robolectric_all-target",
     ],
     // these are needed for Extended Mockito
diff --git a/nearby/tests/unit/AndroidManifest.xml b/nearby/tests/unit/AndroidManifest.xml
index 9f58baf..7dcb263 100644
--- a/nearby/tests/unit/AndroidManifest.xml
+++ b/nearby/tests/unit/AndroidManifest.xml
@@ -23,6 +23,7 @@
     <uses-permission android:name="android.permission.BLUETOOTH" />
     <uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
     <uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
+    <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
 
     <application android:debuggable="true">
         <uses-library android:name="android.test.runner" />
diff --git a/nearby/tests/unit/src/android/nearby/FastPairDeviceTest.java b/nearby/tests/unit/src/android/nearby/FastPairDeviceTest.java
new file mode 100644
index 0000000..edda3c2
--- /dev/null
+++ b/nearby/tests/unit/src/android/nearby/FastPairDeviceTest.java
@@ -0,0 +1,100 @@
+/*
+ * 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 android.nearby;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.os.Parcel;
+
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class FastPairDeviceTest {
+    private static final String NAME = "name";
+    private static final byte[] DATA = new byte[] {0x01, 0x02};
+    private static final String MODEL_ID = "112233";
+    private static final int RSSI = -80;
+    private static final int TX_POWER = -10;
+    private static final String MAC_ADDRESS = "00:11:22:33:44:55";
+    private static List<Integer> sMediums = new ArrayList<Integer>(List.of(1));
+    private static FastPairDevice sDevice;
+
+
+    @Before
+    public void setup() {
+        sDevice = new FastPairDevice(NAME, sMediums, RSSI, TX_POWER, MODEL_ID, MAC_ADDRESS, DATA);
+    }
+
+    @Test
+    public void testParcelable() {
+        Parcel dest = Parcel.obtain();
+        sDevice.writeToParcel(dest, 0);
+        dest.setDataPosition(0);
+        FastPairDevice compareDevice = FastPairDevice.CREATOR.createFromParcel(dest);
+        assertThat(compareDevice.getName()).isEqualTo(NAME);
+        assertThat(compareDevice.getMediums()).isEqualTo(sMediums);
+        assertThat(compareDevice.getRssi()).isEqualTo(RSSI);
+        assertThat(compareDevice.getTxPower()).isEqualTo(TX_POWER);
+        assertThat(compareDevice.getModelId()).isEqualTo(MODEL_ID);
+        assertThat(compareDevice.getBluetoothAddress()).isEqualTo(MAC_ADDRESS);
+        assertThat(compareDevice.getData()).isEqualTo(DATA);
+        assertThat(compareDevice.equals(sDevice)).isTrue();
+        assertThat(compareDevice.hashCode()).isEqualTo(sDevice.hashCode());
+    }
+
+    @Test
+    public void describeContents() {
+        assertThat(sDevice.describeContents()).isEqualTo(0);
+    }
+
+    @Test
+    public void testToString() {
+        assertThat(sDevice.toString()).isEqualTo(
+                "FastPairDevice [name=name, medium={BLE} "
+                        + "rssi=-80 txPower=-10 "
+                        + "modelId=112233 bluetoothAddress=00:11:22:33:44:55]");
+    }
+
+    @Test
+    public void testCreatorNewArray() {
+        FastPairDevice[] fastPairDevices = FastPairDevice.CREATOR.newArray(2);
+        assertThat(fastPairDevices.length).isEqualTo(2);
+    }
+
+    @Test
+    public void testBuilder() {
+        FastPairDevice.Builder builder = new FastPairDevice.Builder();
+        FastPairDevice compareDevice = builder.setName(NAME)
+                .addMedium(1)
+                .setBluetoothAddress(MAC_ADDRESS)
+                .setRssi(RSSI)
+                .setTxPower(TX_POWER)
+                .setData(DATA)
+                .setModelId(MODEL_ID)
+                .build();
+        assertThat(compareDevice.getName()).isEqualTo(NAME);
+        assertThat(compareDevice.getMediums()).isEqualTo(sMediums);
+        assertThat(compareDevice.getRssi()).isEqualTo(RSSI);
+        assertThat(compareDevice.getTxPower()).isEqualTo(TX_POWER);
+        assertThat(compareDevice.getModelId()).isEqualTo(MODEL_ID);
+        assertThat(compareDevice.getBluetoothAddress()).isEqualTo(MAC_ADDRESS);
+        assertThat(compareDevice.getData()).isEqualTo(DATA);
+    }
+}
diff --git a/nearby/tests/cts/fastpair/src/android/nearby/cts/NearbyDeviceParcelableTest.java b/nearby/tests/unit/src/android/nearby/NearbyDeviceParcelableTest.java
similarity index 60%
rename from nearby/tests/cts/fastpair/src/android/nearby/cts/NearbyDeviceParcelableTest.java
rename to nearby/tests/unit/src/android/nearby/NearbyDeviceParcelableTest.java
index 654b852..a4909b2 100644
--- a/nearby/tests/cts/fastpair/src/android/nearby/cts/NearbyDeviceParcelableTest.java
+++ b/nearby/tests/unit/src/android/nearby/NearbyDeviceParcelableTest.java
@@ -1,5 +1,5 @@
 /*
- * Copyright (C) 2022 The Android Open Source Project
+ * 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.
@@ -14,20 +14,17 @@
  * limitations under the License.
  */
 
-package android.nearby.cts;
+package android.nearby;
 
 import static android.nearby.ScanRequest.SCAN_TYPE_NEARBY_PRESENCE;
 
 import static com.google.common.truth.Truth.assertThat;
 
-import android.nearby.NearbyDevice;
-import android.nearby.NearbyDeviceParcelable;
 import android.os.Build;
 import android.os.Parcel;
 
 import androidx.annotation.RequiresApi;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
-import androidx.test.filters.SdkSuppress;
 
 import org.junit.Before;
 import org.junit.Test;
@@ -39,10 +36,15 @@
 @RequiresApi(Build.VERSION_CODES.TIRAMISU)
 public class NearbyDeviceParcelableTest {
 
+    private static final long DEVICE_ID = 1234;
     private static final String BLUETOOTH_ADDRESS = "00:11:22:33:FF:EE";
     private static final byte[] SCAN_DATA = new byte[] {1, 2, 3, 4};
+    private static final byte[] SALT = new byte[] {1, 2, 3, 4};
     private static final String FAST_PAIR_MODEL_ID = "1234";
     private static final int RSSI = -60;
+    private static final int TX_POWER = -10;
+    private static final int ACTION = 1;
+    private static final int MEDIUM_BLE = 1;
 
     private NearbyDeviceParcelable.Builder mBuilder;
 
@@ -50,9 +52,10 @@
     public void setUp() {
         mBuilder =
                 new NearbyDeviceParcelable.Builder()
+                        .setDeviceId(DEVICE_ID)
                         .setScanType(SCAN_TYPE_NEARBY_PRESENCE)
                         .setName("testDevice")
-                        .setMedium(NearbyDevice.Medium.BLE)
+                        .setMedium(MEDIUM_BLE)
                         .setRssi(RSSI)
                         .setFastPairModelId(FAST_PAIR_MODEL_ID)
                         .setBluetoothAddress(BLUETOOTH_ADDRESS)
@@ -60,25 +63,40 @@
     }
 
     @Test
-    @SdkSuppress(minSdkVersion = 33, codeName = "T")
-    public void test_defaultNullFields() {
+    public void testNullFields() {
+        PublicCredential publicCredential =
+                new PublicCredential.Builder(
+                        new byte[] {1},
+                        new byte[] {2},
+                        new byte[] {3},
+                        new byte[] {4},
+                        new byte[] {5})
+                        .build();
         NearbyDeviceParcelable nearbyDeviceParcelable =
                 new NearbyDeviceParcelable.Builder()
-                        .setMedium(NearbyDevice.Medium.BLE)
+                        .setMedium(MEDIUM_BLE)
+                        .setPublicCredential(publicCredential)
+                        .setAction(ACTION)
                         .setRssi(RSSI)
+                        .setScanType(SCAN_TYPE_NEARBY_PRESENCE)
+                        .setTxPower(TX_POWER)
+                        .setSalt(SALT)
                         .build();
 
+        assertThat(nearbyDeviceParcelable.getDeviceId()).isEqualTo(-1);
         assertThat(nearbyDeviceParcelable.getName()).isNull();
         assertThat(nearbyDeviceParcelable.getFastPairModelId()).isNull();
         assertThat(nearbyDeviceParcelable.getBluetoothAddress()).isNull();
         assertThat(nearbyDeviceParcelable.getData()).isNull();
-
-        assertThat(nearbyDeviceParcelable.getMedium()).isEqualTo(NearbyDevice.Medium.BLE);
+        assertThat(nearbyDeviceParcelable.getMedium()).isEqualTo(MEDIUM_BLE);
         assertThat(nearbyDeviceParcelable.getRssi()).isEqualTo(RSSI);
+        assertThat(nearbyDeviceParcelable.getAction()).isEqualTo(ACTION);
+        assertThat(nearbyDeviceParcelable.getPublicCredential()).isEqualTo(publicCredential);
+        assertThat(nearbyDeviceParcelable.getSalt()).isEqualTo(SALT);
+        assertThat(nearbyDeviceParcelable.getTxPower()).isEqualTo(TX_POWER);
     }
 
     @Test
-    @SdkSuppress(minSdkVersion = 33, codeName = "T")
     public void testWriteParcel() {
         NearbyDeviceParcelable nearbyDeviceParcelable = mBuilder.build();
 
@@ -89,6 +107,7 @@
                 NearbyDeviceParcelable.CREATOR.createFromParcel(parcel);
         parcel.recycle();
 
+        assertThat(actualNearbyDevice.getDeviceId()).isEqualTo(DEVICE_ID);
         assertThat(actualNearbyDevice.getRssi()).isEqualTo(RSSI);
         assertThat(actualNearbyDevice.getFastPairModelId()).isEqualTo(FAST_PAIR_MODEL_ID);
         assertThat(actualNearbyDevice.getBluetoothAddress()).isEqualTo(BLUETOOTH_ADDRESS);
@@ -96,7 +115,6 @@
     }
 
     @Test
-    @SdkSuppress(minSdkVersion = 33, codeName = "T")
     public void testWriteParcel_nullModelId() {
         NearbyDeviceParcelable nearbyDeviceParcelable = mBuilder.setFastPairModelId(null).build();
 
@@ -111,10 +129,8 @@
     }
 
     @Test
-    @SdkSuppress(minSdkVersion = 33, codeName = "T")
     public void testWriteParcel_nullBluetoothAddress() {
         NearbyDeviceParcelable nearbyDeviceParcelable = mBuilder.setBluetoothAddress(null).build();
-
         Parcel parcel = Parcel.obtain();
         nearbyDeviceParcelable.writeToParcel(parcel, 0);
         parcel.setDataPosition(0);
@@ -124,4 +140,34 @@
 
         assertThat(actualNearbyDevice.getBluetoothAddress()).isNull();
     }
+
+    @Test
+    public void describeContents() {
+        NearbyDeviceParcelable nearbyDeviceParcelable = mBuilder.setBluetoothAddress(null).build();
+        assertThat(nearbyDeviceParcelable.describeContents()).isEqualTo(0);
+    }
+
+    @Test
+    public void testEqual() {
+        PublicCredential publicCredential =
+                new PublicCredential.Builder(
+                        new byte[] {1},
+                        new byte[] {2},
+                        new byte[] {3},
+                        new byte[] {4},
+                        new byte[] {5})
+                        .build();
+        NearbyDeviceParcelable nearbyDeviceParcelable1 =
+                mBuilder.setPublicCredential(publicCredential).build();
+        NearbyDeviceParcelable nearbyDeviceParcelable2 =
+                mBuilder.setPublicCredential(publicCredential).build();
+        assertThat(nearbyDeviceParcelable1.equals(nearbyDeviceParcelable2)).isTrue();
+    }
+
+    @Test
+    public void testCreatorNewArray() {
+        NearbyDeviceParcelable[] nearbyDeviceParcelables =
+                NearbyDeviceParcelable.CREATOR.newArray(2);
+        assertThat(nearbyDeviceParcelables.length).isEqualTo(2);
+    }
 }
diff --git a/nearby/tests/unit/src/android/nearby/ScanRequestTest.java b/nearby/tests/unit/src/android/nearby/ScanRequestTest.java
index 12de30e..6020c7e 100644
--- a/nearby/tests/unit/src/android/nearby/ScanRequestTest.java
+++ b/nearby/tests/unit/src/android/nearby/ScanRequestTest.java
@@ -24,11 +24,14 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
+import android.os.Build;
 import android.os.Parcel;
 import android.os.WorkSource;
 import android.platform.test.annotations.Presubmit;
 
+import androidx.annotation.RequiresApi;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SdkSuppress;
 import androidx.test.filters.SmallTest;
 
 import org.junit.Test;
@@ -38,14 +41,15 @@
 @Presubmit
 @SmallTest
 @RunWith(AndroidJUnit4.class)
+@RequiresApi(Build.VERSION_CODES.TIRAMISU)
 public class ScanRequestTest {
 
     private static final int RSSI = -40;
+    private static final int UID = 1001;
+    private static final String APP_NAME = "android.nearby.tests";
 
     private static WorkSource getWorkSource() {
-        final int uid = 1001;
-        final String appName = "android.nearby.tests";
-        return new WorkSource(uid, appName);
+        return new WorkSource(UID, APP_NAME);
     }
 
     /** Test creating a scan request. */
@@ -104,6 +108,7 @@
 
     /** Verify toString returns expected string. */
     @Test
+    @SdkSuppress(minSdkVersion = 34)
     public void testToString() {
         WorkSource workSource = getWorkSource();
         ScanRequest request = new ScanRequest.Builder()
@@ -115,28 +120,28 @@
 
         assertThat(request.toString()).isEqualTo(
                 "Request[scanType=1, scanMode=SCAN_MODE_BALANCED, "
-                        + "enableBle=true, workSource=WorkSource{1001 android.nearby.tests}, "
-                        + "scanFilters=[]]");
+                        + "bleEnabled=true, offloadOnly=false, "
+                        + "workSource=WorkSource{" + UID + " " + APP_NAME + "}, scanFilters=[]]");
     }
 
     /** Verify toString works correctly with null WorkSource. */
     @Test
-    public void testToString_nullWorkSource() {
+    @SdkSuppress(minSdkVersion = 34)
+    public void testToString_nullWorkSource_offloadOnly() {
         ScanRequest request = new ScanRequest.Builder().setScanType(
-                SCAN_TYPE_FAST_PAIR).setWorkSource(null).build();
+                SCAN_TYPE_FAST_PAIR).setWorkSource(null).setOffloadOnly(true).build();
 
         assertThat(request.toString()).isEqualTo("Request[scanType=1, "
-                + "scanMode=SCAN_MODE_LOW_POWER, enableBle=true, workSource=WorkSource{}, "
-                + "scanFilters=[]]");
+                + "scanMode=SCAN_MODE_LOW_POWER, bleEnabled=true, offloadOnly=true, "
+                + "workSource=WorkSource{}, scanFilters=[]]");
     }
 
     /** Verify writing and reading from parcel for scan request. */
     @Test
     public void testParceling() {
-        final int scanType = SCAN_TYPE_NEARBY_PRESENCE;
         WorkSource workSource = getWorkSource();
         ScanRequest originalRequest = new ScanRequest.Builder()
-                .setScanType(scanType)
+                .setScanType(SCAN_TYPE_NEARBY_PRESENCE)
                 .setScanMode(SCAN_MODE_BALANCED)
                 .setBleEnabled(true)
                 .setWorkSource(workSource)
diff --git a/nearby/tests/unit/src/com/android/server/nearby/NearbyConfigurationTest.java b/nearby/tests/unit/src/com/android/server/nearby/NearbyConfigurationTest.java
new file mode 100644
index 0000000..5ddfed3
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/NearbyConfigurationTest.java
@@ -0,0 +1,86 @@
+/*
+ * 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.nearby;
+
+import static android.Manifest.permission.READ_DEVICE_CONFIG;
+import static android.Manifest.permission.WRITE_DEVICE_CONFIG;
+
+import static com.android.server.nearby.NearbyConfiguration.NEARBY_ENABLE_PRESENCE_BROADCAST_LEGACY;
+import static com.android.server.nearby.NearbyConfiguration.NEARBY_MAINLINE_NANO_APP_MIN_VERSION;
+import static com.android.server.nearby.NearbyConfiguration.NEARBY_SUPPORT_TEST_APP;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.provider.DeviceConfig;
+
+import androidx.test.platform.app.InstrumentationRegistry;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public final class NearbyConfigurationTest {
+
+    private static final String NAMESPACE = NearbyConfiguration.getNamespace();
+    private NearbyConfiguration mNearbyConfiguration;
+
+    @Before
+    public void setUp() {
+        InstrumentationRegistry.getInstrumentation().getUiAutomation()
+                .adoptShellPermissionIdentity(WRITE_DEVICE_CONFIG, READ_DEVICE_CONFIG);
+    }
+
+    @Test
+    public void testDeviceConfigChanged() throws InterruptedException {
+        mNearbyConfiguration = new NearbyConfiguration();
+
+        DeviceConfig.setProperty(NAMESPACE, NEARBY_SUPPORT_TEST_APP,
+                "false", false);
+        DeviceConfig.setProperty(NAMESPACE, NEARBY_ENABLE_PRESENCE_BROADCAST_LEGACY,
+                "false", false);
+        DeviceConfig.setProperty(NAMESPACE, NEARBY_MAINLINE_NANO_APP_MIN_VERSION,
+                "1", false);
+        Thread.sleep(500);
+
+        assertThat(mNearbyConfiguration.isTestAppSupported()).isFalse();
+        assertThat(mNearbyConfiguration.isPresenceBroadcastLegacyEnabled()).isFalse();
+        assertThat(mNearbyConfiguration.getNanoAppMinVersion()).isEqualTo(1);
+
+        DeviceConfig.setProperty(NAMESPACE, NEARBY_SUPPORT_TEST_APP,
+                "true", false);
+        DeviceConfig.setProperty(NAMESPACE, NEARBY_ENABLE_PRESENCE_BROADCAST_LEGACY,
+                "true", false);
+        DeviceConfig.setProperty(NAMESPACE, NEARBY_MAINLINE_NANO_APP_MIN_VERSION,
+                "3", false);
+        Thread.sleep(500);
+
+        assertThat(mNearbyConfiguration.isTestAppSupported()).isTrue();
+        assertThat(mNearbyConfiguration.isPresenceBroadcastLegacyEnabled()).isTrue();
+        assertThat(mNearbyConfiguration.getNanoAppMinVersion()).isEqualTo(3);
+    }
+
+    @After
+    public void tearDown() {
+        // Sets DeviceConfig values back to default
+        DeviceConfig.setProperty(NAMESPACE, NEARBY_SUPPORT_TEST_APP,
+                "false", true);
+        DeviceConfig.setProperty(NAMESPACE, NEARBY_ENABLE_PRESENCE_BROADCAST_LEGACY,
+                "false", true);
+        DeviceConfig.setProperty(NAMESPACE, NEARBY_MAINLINE_NANO_APP_MIN_VERSION,
+                "0", true);
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/NearbyServiceTest.java b/nearby/tests/unit/src/com/android/server/nearby/NearbyServiceTest.java
index 8a18cca..5b640cc 100644
--- a/nearby/tests/unit/src/com/android/server/nearby/NearbyServiceTest.java
+++ b/nearby/tests/unit/src/com/android/server/nearby/NearbyServiceTest.java
@@ -18,6 +18,9 @@
 
 import static android.Manifest.permission.BLUETOOTH_PRIVILEGED;
 import static android.Manifest.permission.READ_DEVICE_CONFIG;
+import static android.Manifest.permission.WRITE_DEVICE_CONFIG;
+
+import static com.android.server.nearby.NearbyConfiguration.NEARBY_SUPPORT_TEST_APP;
 
 import static org.junit.Assert.assertThrows;
 import static org.mockito.ArgumentMatchers.anyInt;
@@ -32,6 +35,8 @@
 import android.content.Context;
 import android.nearby.IScanListener;
 import android.nearby.ScanRequest;
+import android.os.IBinder;
+import android.provider.DeviceConfig;
 
 import androidx.test.platform.app.InstrumentationRegistry;
 
@@ -45,6 +50,7 @@
 
 public final class NearbyServiceTest {
 
+    private static final String NAMESPACE = NearbyConfiguration.getNamespace();
     private static final String PACKAGE_NAME = "android.nearby.test";
     private Context mContext;
     private NearbyService mService;
@@ -56,11 +62,16 @@
     private IScanListener mScanListener;
     @Mock
     private AppOpsManager mMockAppOpsManager;
+    @Mock
+    private IBinder mIBinder;
 
     @Before
     public void setUp()  {
         initMocks(this);
-        mUiAutomation.adoptShellPermissionIdentity(READ_DEVICE_CONFIG, BLUETOOTH_PRIVILEGED);
+        when(mScanListener.asBinder()).thenReturn(mIBinder);
+
+        mUiAutomation.adoptShellPermissionIdentity(
+                READ_DEVICE_CONFIG, WRITE_DEVICE_CONFIG, BLUETOOTH_PRIVILEGED);
         mContext = InstrumentationRegistry.getInstrumentation().getContext();
         mService = new NearbyService(mContext);
         mScanRequest = createScanRequest();
@@ -80,6 +91,8 @@
 
     @Test
     public void test_register_noPrivilegedPermission_throwsException() {
+        DeviceConfig.setProperty(NAMESPACE, NEARBY_SUPPORT_TEST_APP,
+                "false", false);
         mUiAutomation.dropShellPermissionIdentity();
         assertThrows(java.lang.SecurityException.class,
                 () -> mService.registerScanListener(mScanRequest, mScanListener, PACKAGE_NAME,
@@ -88,6 +101,8 @@
 
     @Test
     public void test_unregister_noPrivilegedPermission_throwsException() {
+        DeviceConfig.setProperty(NAMESPACE, NEARBY_SUPPORT_TEST_APP,
+                "false", false);
         mUiAutomation.dropShellPermissionIdentity();
         assertThrows(java.lang.SecurityException.class,
                 () -> mService.unregisterScanListener(mScanListener, PACKAGE_NAME,
diff --git a/nearby/tests/unit/src/com/android/server/nearby/common/CancelableAlarmTest.java b/nearby/tests/unit/src/com/android/server/nearby/common/CancelableAlarmTest.java
new file mode 100644
index 0000000..719e816
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/common/CancelableAlarmTest.java
@@ -0,0 +1,132 @@
+/*
+ * 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.nearby.common;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.os.SystemClock;
+
+import org.junit.Test;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+public class CancelableAlarmTest {
+
+    private static final long DELAY_MILLIS = 1000;
+
+    private final ScheduledExecutorService mExecutor =
+            Executors.newScheduledThreadPool(1);
+
+    @Test
+    public void alarmRuns_singleExecution() throws InterruptedException {
+        TestCountDownLatch latch = new TestCountDownLatch(1);
+        CancelableAlarm.createSingleAlarm(
+                "alarmRuns", new CountDownRunnable(latch), DELAY_MILLIS, mExecutor);
+        latch.awaitAndExpectDelay(DELAY_MILLIS);
+    }
+
+    @Test
+    public void alarmRuns_periodicExecution() throws InterruptedException {
+        TestCountDownLatch latch = new TestCountDownLatch(2);
+        CancelableAlarm.createRecurringAlarm(
+                "alarmRunsPeriodically", new CountDownRunnable(latch), DELAY_MILLIS, mExecutor);
+        latch.awaitAndExpectDelay(DELAY_MILLIS * 2);
+    }
+
+    @Test
+    public void canceledAlarmDoesNotRun_singleExecution() throws InterruptedException {
+        TestCountDownLatch latch = new TestCountDownLatch(1);
+        CancelableAlarm alarm =
+                CancelableAlarm.createSingleAlarm(
+                        "canceledAlarmDoesNotRun",
+                        new CountDownRunnable(latch),
+                        DELAY_MILLIS,
+                        mExecutor);
+        assertThat(alarm.cancel()).isTrue();
+        latch.awaitAndExpectTimeout(DELAY_MILLIS);
+    }
+
+    @Test
+    public void canceledAlarmDoesNotRun_periodicExecution() throws InterruptedException {
+        TestCountDownLatch latch = new TestCountDownLatch(2);
+        CancelableAlarm alarm =
+                CancelableAlarm.createRecurringAlarm(
+                        "canceledAlarmDoesNotRunPeriodically",
+                        new CountDownRunnable(latch),
+                        DELAY_MILLIS,
+                        mExecutor);
+        latch.awaitAndExpectTimeout(DELAY_MILLIS);
+        assertThat(alarm.cancel()).isTrue();
+        latch.awaitAndExpectTimeout(DELAY_MILLIS);
+    }
+
+    @Test
+    public void cancelOfRunAlarmReturnsFalse() throws InterruptedException {
+        TestCountDownLatch latch = new TestCountDownLatch(1);
+        long delayMillis = 500;
+        CancelableAlarm alarm =
+                CancelableAlarm.createSingleAlarm(
+                        "cancelOfRunAlarmReturnsFalse",
+                        new CountDownRunnable(latch),
+                        delayMillis,
+                        mExecutor);
+        latch.awaitAndExpectDelay(delayMillis - 1);
+
+        assertThat(alarm.cancel()).isFalse();
+    }
+
+    private static class CountDownRunnable implements Runnable {
+        private final CountDownLatch mLatch;
+
+        CountDownRunnable(CountDownLatch latch) {
+            this.mLatch = latch;
+        }
+
+        @Override
+        public void run() {
+            mLatch.countDown();
+        }
+    }
+
+    /** A CountDownLatch for test with extra test features like throw exception on await(). */
+    private static class TestCountDownLatch extends CountDownLatch {
+
+        TestCountDownLatch(int count) {
+            super(count);
+        }
+
+        /**
+         * Asserts that the latch does not go off until delayMillis has passed and that it does in
+         * fact go off after delayMillis has passed.
+         */
+        public void awaitAndExpectDelay(long delayMillis) throws InterruptedException {
+            SystemClock.sleep(delayMillis - 1);
+            assertThat(await(0, TimeUnit.MILLISECONDS)).isFalse();
+            SystemClock.sleep(10);
+            assertThat(await(0, TimeUnit.MILLISECONDS)).isTrue();
+        }
+
+        /** Asserts that the latch does not go off within delayMillis. */
+        public void awaitAndExpectTimeout(long delayMillis) throws InterruptedException {
+            SystemClock.sleep(delayMillis + 1);
+            assertThat(await(0, TimeUnit.MILLISECONDS)).isFalse();
+        }
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/common/CancellationFlagTest.java b/nearby/tests/unit/src/com/android/server/nearby/common/CancellationFlagTest.java
new file mode 100644
index 0000000..eb6316e
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/common/CancellationFlagTest.java
@@ -0,0 +1,52 @@
+/*
+ * 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.nearby.common;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+
+import java.util.concurrent.atomic.AtomicInteger;
+
+public class CancellationFlagTest {
+
+    @Test
+    public void initialValueIsFalse() {
+        assertThat(new CancellationFlag().isCancelled()).isFalse();
+    }
+
+    @Test
+    public void cancel() {
+        CancellationFlag flag = new CancellationFlag();
+        flag.cancel();
+        assertThat(flag.isCancelled()).isTrue();
+    }
+
+    @Test
+    public void cancelShouldOnlyCancelOnce() {
+        CancellationFlag flag = new CancellationFlag();
+        AtomicInteger record = new AtomicInteger();
+
+        flag.registerOnCancelListener(() -> record.incrementAndGet());
+        for (int i = 0; i < 3; i++) {
+            flag.cancel();
+        }
+
+        assertThat(flag.isCancelled()).isTrue();
+        assertThat(record.get()).isEqualTo(1);
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/injector/ContextHubManagerAdapterTest.java b/nearby/tests/unit/src/com/android/server/nearby/injector/ContextHubManagerAdapterTest.java
new file mode 100644
index 0000000..b577064
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/injector/ContextHubManagerAdapterTest.java
@@ -0,0 +1,48 @@
+/*
+ * 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.nearby.injector;
+
+import android.hardware.location.ContextHubInfo;
+import android.hardware.location.ContextHubManager;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+public class ContextHubManagerAdapterTest {
+    private ContextHubManagerAdapter mContextHubManagerAdapter;
+
+    @Mock
+    ContextHubManager mContextHubManager;
+
+    @Before
+    public void setup() {
+        MockitoAnnotations.initMocks(this);
+        mContextHubManagerAdapter = new ContextHubManagerAdapter(mContextHubManager);
+    }
+
+    @Test
+    public void getContextHubs() {
+        mContextHubManagerAdapter.getContextHubs();
+    }
+
+    @Test
+    public void queryNanoApps() {
+        mContextHubManagerAdapter.queryNanoApps(new ContextHubInfo());
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/provider/BroadcastProviderManagerTest.java b/nearby/tests/unit/src/com/android/server/nearby/managers/BroadcastProviderManagerTest.java
similarity index 79%
rename from nearby/tests/unit/src/com/android/server/nearby/provider/BroadcastProviderManagerTest.java
rename to nearby/tests/unit/src/com/android/server/nearby/managers/BroadcastProviderManagerTest.java
index d45d570..bc38210 100644
--- a/nearby/tests/unit/src/com/android/server/nearby/provider/BroadcastProviderManagerTest.java
+++ b/nearby/tests/unit/src/com/android/server/nearby/managers/BroadcastProviderManagerTest.java
@@ -1,5 +1,5 @@
 /*
- * Copyright (C) 2022 The Android Open Source Project
+ * 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.
@@ -14,13 +14,13 @@
  * limitations under the License.
  */
 
-package com.android.server.nearby.provider;
+package com.android.server.nearby.managers;
 
 import static android.Manifest.permission.READ_DEVICE_CONFIG;
 import static android.Manifest.permission.WRITE_DEVICE_CONFIG;
-import static android.provider.DeviceConfig.NAMESPACE_TETHERING;
 
 import static com.android.server.nearby.NearbyConfiguration.NEARBY_ENABLE_PRESENCE_BROADCAST_LEGACY;
+import static com.android.server.nearby.NearbyConfiguration.NEARBY_SUPPORT_TEST_APP;
 
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.eq;
@@ -39,6 +39,9 @@
 import androidx.test.core.app.ApplicationProvider;
 import androidx.test.platform.app.InstrumentationRegistry;
 
+import com.android.server.nearby.NearbyConfiguration;
+import com.android.server.nearby.provider.BleBroadcastProvider;
+
 import com.google.common.util.concurrent.MoreExecutors;
 
 import org.junit.Before;
@@ -51,9 +54,10 @@
 import java.util.Collections;
 
 /**
- * Unit test for {@link BroadcastProviderManager}.
+ * Unit test for {@link com.android.server.nearby.managers.BroadcastProviderManager}.
  */
 public class BroadcastProviderManagerTest {
+    private static final String NAMESPACE = NearbyConfiguration.getNamespace();
     private static final byte[] IDENTITY = new byte[]{1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1};
     private static final int MEDIUM_TYPE_BLE = 0;
     private static final byte[] SALT = {2, 3};
@@ -79,11 +83,12 @@
     @Before
     public void setUp() {
         mUiAutomation.adoptShellPermissionIdentity(WRITE_DEVICE_CONFIG, READ_DEVICE_CONFIG);
-        DeviceConfig.setProperty(NAMESPACE_TETHERING, NEARBY_ENABLE_PRESENCE_BROADCAST_LEGACY,
-                "true", false);
+        DeviceConfig.setProperty(
+                NAMESPACE, NEARBY_ENABLE_PRESENCE_BROADCAST_LEGACY, "true", false);
 
         mContext = ApplicationProvider.getApplicationContext();
-        mBroadcastProviderManager = new BroadcastProviderManager(MoreExecutors.directExecutor(),
+        mBroadcastProviderManager = new BroadcastProviderManager(
+                MoreExecutors.directExecutor(),
                 mBleBroadcastProvider);
 
         PrivateCredential privateCredential =
@@ -101,14 +106,22 @@
     @Test
     public void testStartAdvertising() {
         mBroadcastProviderManager.startBroadcast(mBroadcastRequest, mBroadcastListener);
-        verify(mBleBroadcastProvider).start(any(byte[].class), any(
-                BleBroadcastProvider.BroadcastListener.class));
+        verify(mBleBroadcastProvider).start(eq(BroadcastRequest.PRESENCE_VERSION_V0),
+                any(byte[].class), any(BleBroadcastProvider.BroadcastListener.class));
+    }
+
+    @Test
+    public void testStopAdvertising() {
+        mBroadcastProviderManager.stopBroadcast(mBroadcastListener);
     }
 
     @Test
     public void testStartAdvertising_featureDisabled() throws Exception {
-        DeviceConfig.setProperty(NAMESPACE_TETHERING, NEARBY_ENABLE_PRESENCE_BROADCAST_LEGACY,
-                "false", false);
+        DeviceConfig.setProperty(
+                NAMESPACE, NEARBY_ENABLE_PRESENCE_BROADCAST_LEGACY, "false", false);
+        DeviceConfig.setProperty(
+                NAMESPACE, NEARBY_SUPPORT_TEST_APP, "false", false);
+
         mBroadcastProviderManager = new BroadcastProviderManager(MoreExecutors.directExecutor(),
                 mBleBroadcastProvider);
         mBroadcastProviderManager.startBroadcast(mBroadcastRequest, mBroadcastListener);
diff --git a/nearby/tests/unit/src/com/android/server/nearby/managers/DiscoveryProviderManagerLegacyTest.java b/nearby/tests/unit/src/com/android/server/nearby/managers/DiscoveryProviderManagerLegacyTest.java
new file mode 100644
index 0000000..aa0dad3
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/managers/DiscoveryProviderManagerLegacyTest.java
@@ -0,0 +1,378 @@
+/*
+ * 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.nearby.managers;
+
+import static android.nearby.PresenceCredential.IDENTITY_TYPE_PRIVATE;
+import static android.nearby.ScanRequest.SCAN_TYPE_NEARBY_PRESENCE;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.app.AppOpsManager;
+import android.content.Context;
+import android.nearby.DataElement;
+import android.nearby.IScanListener;
+import android.nearby.NearbyDeviceParcelable;
+import android.nearby.PresenceScanFilter;
+import android.nearby.PublicCredential;
+import android.nearby.ScanRequest;
+import android.os.IBinder;
+
+import com.android.server.nearby.injector.Injector;
+import com.android.server.nearby.provider.BleDiscoveryProvider;
+import com.android.server.nearby.provider.ChreCommunication;
+import com.android.server.nearby.provider.ChreDiscoveryProvider;
+import com.android.server.nearby.provider.DiscoveryProviderController;
+import com.android.server.nearby.util.identity.CallerIdentity;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+/**
+ * Unit test for {@link DiscoveryProviderManagerLegacy} class.
+ */
+public class DiscoveryProviderManagerLegacyTest {
+    private static final int SCAN_MODE_CHRE_ONLY = 3;
+    private static final int DATA_TYPE_SCAN_MODE = 102;
+    private static final int UID = 1234;
+    private static final int PID = 5678;
+    private static final String PACKAGE_NAME = "android.nearby.test";
+    private static final int RSSI = -60;
+    @Mock
+    Injector mInjector;
+    @Mock
+    Context mContext;
+    @Mock
+    AppOpsManager mAppOpsManager;
+    @Mock
+    BleDiscoveryProvider mBleDiscoveryProvider;
+    @Mock
+    ChreDiscoveryProvider mChreDiscoveryProvider;
+    @Mock
+    DiscoveryProviderController mBluetoothController;
+    @Mock
+    DiscoveryProviderController mChreController;
+    @Mock
+    IScanListener mScanListener;
+    @Mock
+    CallerIdentity mCallerIdentity;
+    @Mock
+    DiscoveryProviderManagerLegacy.ScanListenerDeathRecipient mScanListenerDeathRecipient;
+    @Mock
+    IBinder mIBinder;
+    private DiscoveryProviderManagerLegacy mDiscoveryProviderManager;
+    private Map<IBinder, DiscoveryProviderManagerLegacy.ScanListenerRecord>
+            mScanTypeScanListenerRecordMap;
+
+    private static PresenceScanFilter getPresenceScanFilter() {
+        final byte[] secretId = new byte[]{1, 2, 3, 4};
+        final byte[] authenticityKey = new byte[]{0, 1, 1, 1};
+        final byte[] publicKey = new byte[]{1, 1, 2, 2};
+        final byte[] encryptedMetadata = new byte[]{1, 2, 3, 4, 5};
+        final byte[] metadataEncryptionKeyTag = new byte[]{1, 1, 3, 4, 5};
+
+        PublicCredential credential = new PublicCredential.Builder(
+                secretId, authenticityKey, publicKey, encryptedMetadata, metadataEncryptionKeyTag)
+                .setIdentityType(IDENTITY_TYPE_PRIVATE)
+                .build();
+
+        final int action = 123;
+        return new PresenceScanFilter.Builder()
+                .addCredential(credential)
+                .setMaxPathLoss(RSSI)
+                .addPresenceAction(action)
+                .build();
+    }
+
+    private static PresenceScanFilter getChreOnlyPresenceScanFilter() {
+        final byte[] secretId = new byte[]{1, 2, 3, 4};
+        final byte[] authenticityKey = new byte[]{0, 1, 1, 1};
+        final byte[] publicKey = new byte[]{1, 1, 2, 2};
+        final byte[] encryptedMetadata = new byte[]{1, 2, 3, 4, 5};
+        final byte[] metadataEncryptionKeyTag = new byte[]{1, 1, 3, 4, 5};
+
+        PublicCredential credential = new PublicCredential.Builder(
+                secretId, authenticityKey, publicKey, encryptedMetadata, metadataEncryptionKeyTag)
+                .setIdentityType(IDENTITY_TYPE_PRIVATE)
+                .build();
+
+        final int action = 123;
+        DataElement scanModeElement = new DataElement(DATA_TYPE_SCAN_MODE,
+                new byte[]{SCAN_MODE_CHRE_ONLY});
+        return new PresenceScanFilter.Builder()
+                .addCredential(credential)
+                .setMaxPathLoss(RSSI)
+                .addPresenceAction(action)
+                .addExtendedProperty(scanModeElement)
+                .build();
+    }
+
+    @Before
+    public void setup() {
+        MockitoAnnotations.initMocks(this);
+        when(mInjector.getAppOpsManager()).thenReturn(mAppOpsManager);
+        when(mBleDiscoveryProvider.getController()).thenReturn(mBluetoothController);
+        when(mChreDiscoveryProvider.getController()).thenReturn(mChreController);
+
+        mScanTypeScanListenerRecordMap = new HashMap<>();
+        mDiscoveryProviderManager =
+                new DiscoveryProviderManagerLegacy(mContext, mInjector,
+                        mBleDiscoveryProvider,
+                        mChreDiscoveryProvider,
+                        mScanTypeScanListenerRecordMap);
+        mCallerIdentity = CallerIdentity
+                .forTest(UID, PID, PACKAGE_NAME, /* attributionTag= */ null);
+    }
+
+    @Test
+    public void testOnNearbyDeviceDiscovered() {
+        NearbyDeviceParcelable nearbyDeviceParcelable = new NearbyDeviceParcelable.Builder()
+                .setScanType(SCAN_TYPE_NEARBY_PRESENCE)
+                .build();
+        mDiscoveryProviderManager.onNearbyDeviceDiscovered(nearbyDeviceParcelable);
+    }
+
+    @Test
+    public void testInvalidateProviderScanMode() {
+        mDiscoveryProviderManager.invalidateProviderScanMode();
+    }
+
+    @Test
+    public void testStartProviders_chreOnlyChreAvailable_bleProviderNotStarted() {
+        when(mChreDiscoveryProvider.available()).thenReturn(true);
+
+        ScanRequest scanRequest = new ScanRequest.Builder()
+                .setScanType(SCAN_TYPE_NEARBY_PRESENCE)
+                .addScanFilter(getChreOnlyPresenceScanFilter()).build();
+        DiscoveryProviderManagerLegacy.ScanListenerRecord record =
+                new DiscoveryProviderManagerLegacy.ScanListenerRecord(
+                        scanRequest, mScanListener,
+                        mCallerIdentity, mScanListenerDeathRecipient);
+        mScanTypeScanListenerRecordMap.put(mIBinder, record);
+
+        Boolean start = mDiscoveryProviderManager.startProviders(scanRequest);
+        verify(mBluetoothController, never()).start();
+        assertThat(start).isTrue();
+    }
+
+    @Test
+    public void testStartProviders_chreOnlyChreAvailable_multipleFilters_bleProviderNotStarted() {
+        when(mChreDiscoveryProvider.available()).thenReturn(true);
+
+        ScanRequest scanRequest = new ScanRequest.Builder()
+                .setScanType(SCAN_TYPE_NEARBY_PRESENCE)
+                .addScanFilter(getChreOnlyPresenceScanFilter())
+                .addScanFilter(getPresenceScanFilter()).build();
+        DiscoveryProviderManagerLegacy.ScanListenerRecord record =
+                new DiscoveryProviderManagerLegacy.ScanListenerRecord(
+                        scanRequest, mScanListener,
+                        mCallerIdentity, mScanListenerDeathRecipient);
+        mScanTypeScanListenerRecordMap.put(mIBinder, record);
+
+        Boolean start = mDiscoveryProviderManager.startProviders(scanRequest);
+        verify(mBluetoothController, never()).start();
+        assertThat(start).isTrue();
+    }
+
+    @Test
+    public void testStartProviders_chreOnlyChreUnavailable_bleProviderNotStarted() {
+        when(mChreDiscoveryProvider.available()).thenReturn(false);
+
+        ScanRequest scanRequest = new ScanRequest.Builder()
+                .setScanType(SCAN_TYPE_NEARBY_PRESENCE)
+                .addScanFilter(getChreOnlyPresenceScanFilter()).build();
+        DiscoveryProviderManagerLegacy.ScanListenerRecord record =
+                new DiscoveryProviderManagerLegacy.ScanListenerRecord(
+                        scanRequest, mScanListener,
+                        mCallerIdentity, mScanListenerDeathRecipient);
+        mScanTypeScanListenerRecordMap.put(mIBinder, record);
+
+        Boolean start = mDiscoveryProviderManager.startProviders(scanRequest);
+        verify(mBluetoothController, never()).start();
+        assertThat(start).isFalse();
+    }
+
+    @Test
+    public void testStartProviders_notChreOnlyChreAvailable_bleProviderNotStarted() {
+        when(mChreDiscoveryProvider.available()).thenReturn(true);
+
+        ScanRequest scanRequest = new ScanRequest.Builder()
+                .setScanType(SCAN_TYPE_NEARBY_PRESENCE)
+                .addScanFilter(getPresenceScanFilter()).build();
+        DiscoveryProviderManagerLegacy.ScanListenerRecord record =
+                new DiscoveryProviderManagerLegacy.ScanListenerRecord(
+                        scanRequest, mScanListener,
+                        mCallerIdentity, mScanListenerDeathRecipient);
+        mScanTypeScanListenerRecordMap.put(mIBinder, record);
+
+        Boolean start = mDiscoveryProviderManager.startProviders(scanRequest);
+        verify(mBluetoothController, never()).start();
+        assertThat(start).isTrue();
+    }
+
+    @Test
+    public void testStartProviders_notChreOnlyChreUnavailable_bleProviderStarted() {
+        when(mChreDiscoveryProvider.available()).thenReturn(false);
+
+        ScanRequest scanRequest = new ScanRequest.Builder()
+                .setScanType(SCAN_TYPE_NEARBY_PRESENCE)
+                .addScanFilter(getPresenceScanFilter()).build();
+        DiscoveryProviderManagerLegacy.ScanListenerRecord record =
+                new DiscoveryProviderManagerLegacy.ScanListenerRecord(
+                        scanRequest, mScanListener,
+                        mCallerIdentity, mScanListenerDeathRecipient);
+        mScanTypeScanListenerRecordMap.put(mIBinder, record);
+
+        Boolean start = mDiscoveryProviderManager.startProviders(scanRequest);
+        verify(mBluetoothController, atLeastOnce()).start();
+        assertThat(start).isTrue();
+    }
+
+    @Test
+    public void testStartProviders_chreOnlyChreUndetermined_bleProviderNotStarted() {
+        when(mChreDiscoveryProvider.available()).thenReturn(null);
+
+        ScanRequest scanRequest = new ScanRequest.Builder()
+                .setScanType(SCAN_TYPE_NEARBY_PRESENCE)
+                .addScanFilter(getChreOnlyPresenceScanFilter()).build();
+        DiscoveryProviderManagerLegacy.ScanListenerRecord record =
+                new DiscoveryProviderManagerLegacy.ScanListenerRecord(
+                        scanRequest, mScanListener,
+                        mCallerIdentity, mScanListenerDeathRecipient);
+        mScanTypeScanListenerRecordMap.put(mIBinder, record);
+
+        Boolean start = mDiscoveryProviderManager.startProviders(scanRequest);
+        verify(mBluetoothController, never()).start();
+        assertThat(start).isNull();
+    }
+
+    @Test
+    public void testStartProviders_notChreOnlyChreUndetermined_bleProviderStarted() {
+        when(mChreDiscoveryProvider.available()).thenReturn(null);
+
+        ScanRequest scanRequest = new ScanRequest.Builder()
+                .setScanType(SCAN_TYPE_NEARBY_PRESENCE)
+                .addScanFilter(getPresenceScanFilter()).build();
+        DiscoveryProviderManagerLegacy.ScanListenerRecord record =
+                new DiscoveryProviderManagerLegacy.ScanListenerRecord(
+                        scanRequest, mScanListener,
+                        mCallerIdentity, mScanListenerDeathRecipient);
+        mScanTypeScanListenerRecordMap.put(mIBinder, record);
+
+        Boolean start = mDiscoveryProviderManager.startProviders(scanRequest);
+        verify(mBluetoothController, atLeastOnce()).start();
+        assertThat(start).isTrue();
+    }
+
+    @Test
+    public void test_stopChreProvider_clearFilters() throws Exception {
+        // Cannot use mocked ChreDiscoveryProvider,
+        // so we cannot use class variable mDiscoveryProviderManager
+        ExecutorService executor = Executors.newSingleThreadExecutor();
+        DiscoveryProviderManagerLegacy manager =
+                new DiscoveryProviderManagerLegacy(mContext, mInjector,
+                        mBleDiscoveryProvider,
+                        new ChreDiscoveryProvider(
+                                mContext,
+                                new ChreCommunication(mInjector, mContext, executor), executor),
+                        mScanTypeScanListenerRecordMap);
+        ScanRequest scanRequest = new ScanRequest.Builder()
+                .setScanType(SCAN_TYPE_NEARBY_PRESENCE)
+                .addScanFilter(getPresenceScanFilter()).build();
+        DiscoveryProviderManagerLegacy.ScanListenerRecord record =
+                new DiscoveryProviderManagerLegacy.ScanListenerRecord(
+                        scanRequest, mScanListener,
+                        mCallerIdentity, mScanListenerDeathRecipient);
+        mScanTypeScanListenerRecordMap.put(mIBinder, record);
+        manager.startChreProvider(List.of(getPresenceScanFilter()));
+        // This is an asynchronized process. The filters will be set in executor thread. So we need
+        // to wait for some time to get the correct result.
+        Thread.sleep(200);
+
+        assertThat(manager.mChreDiscoveryProvider.getController().isStarted())
+                .isTrue();
+        assertThat(manager.mChreDiscoveryProvider.getFiltersLocked()).isNotNull();
+
+        manager.stopChreProvider();
+        Thread.sleep(200);
+        // The filters should be cleared right after.
+        assertThat(manager.mChreDiscoveryProvider.getController().isStarted())
+                .isFalse();
+        assertThat(manager.mChreDiscoveryProvider.getFiltersLocked()).isEmpty();
+    }
+
+    @Test
+    public void test_restartChreProvider() throws Exception {
+        // Cannot use mocked ChreDiscoveryProvider,
+        // so we cannot use class variable mDiscoveryProviderManager
+        ExecutorService executor = Executors.newSingleThreadExecutor();
+        DiscoveryProviderManagerLegacy manager =
+                new DiscoveryProviderManagerLegacy(mContext, mInjector,
+                        mBleDiscoveryProvider,
+                        new ChreDiscoveryProvider(
+                                mContext,
+                                new ChreCommunication(mInjector, mContext, executor), executor),
+                        mScanTypeScanListenerRecordMap);
+        ScanRequest scanRequest = new ScanRequest.Builder()
+                .setScanType(SCAN_TYPE_NEARBY_PRESENCE)
+                .addScanFilter(getPresenceScanFilter()).build();
+        DiscoveryProviderManagerLegacy.ScanListenerRecord record =
+                new DiscoveryProviderManagerLegacy.ScanListenerRecord(scanRequest, mScanListener,
+                        mCallerIdentity, mScanListenerDeathRecipient);
+        mScanTypeScanListenerRecordMap.put(mIBinder, record);
+        manager.startChreProvider(List.of(getPresenceScanFilter()));
+        // This is an asynchronized process. The filters will be set in executor thread. So we need
+        // to wait for some time to get the correct result.
+        Thread.sleep(200);
+
+        assertThat(manager.mChreDiscoveryProvider.getController().isStarted())
+                .isTrue();
+        assertThat(manager.mChreDiscoveryProvider.getFiltersLocked()).isNotNull();
+
+        // We want to make sure quickly restart the provider the filters should
+        // be reset correctly.
+        // See b/255922206, there can be a race condition that filters get cleared because onStop()
+        // get executed after onStart() if they are called from different threads.
+        manager.stopChreProvider();
+        manager.mChreDiscoveryProvider.getController().setProviderScanFilters(
+                List.of(getPresenceScanFilter()));
+        manager.startChreProvider(List.of(getPresenceScanFilter()));
+        Thread.sleep(200);
+        assertThat(manager.mChreDiscoveryProvider.getController().isStarted())
+                .isTrue();
+        assertThat(manager.mChreDiscoveryProvider.getFiltersLocked()).isNotNull();
+
+        // Wait for enough time
+        Thread.sleep(1000);
+
+        assertThat(manager.mChreDiscoveryProvider.getController().isStarted())
+                .isTrue();
+        assertThat(manager.mChreDiscoveryProvider.getFiltersLocked()).isNotNull();
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/managers/DiscoveryProviderManagerTest.java b/nearby/tests/unit/src/com/android/server/nearby/managers/DiscoveryProviderManagerTest.java
new file mode 100644
index 0000000..7ecf631
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/managers/DiscoveryProviderManagerTest.java
@@ -0,0 +1,339 @@
+/*
+ * 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.nearby.managers;
+
+import static android.nearby.PresenceCredential.IDENTITY_TYPE_PRIVATE;
+import static android.nearby.ScanRequest.SCAN_TYPE_NEARBY_PRESENCE;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.app.AppOpsManager;
+import android.content.Context;
+import android.nearby.DataElement;
+import android.nearby.IScanListener;
+import android.nearby.NearbyDeviceParcelable;
+import android.nearby.PresenceScanFilter;
+import android.nearby.PublicCredential;
+import android.nearby.ScanRequest;
+import android.os.IBinder;
+
+import com.android.server.nearby.injector.Injector;
+import com.android.server.nearby.provider.BleDiscoveryProvider;
+import com.android.server.nearby.provider.ChreCommunication;
+import com.android.server.nearby.provider.ChreDiscoveryProvider;
+import com.android.server.nearby.provider.DiscoveryProviderController;
+import com.android.server.nearby.util.identity.CallerIdentity;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.util.List;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+
+public class DiscoveryProviderManagerTest {
+    private static final int SCAN_MODE_CHRE_ONLY = 3;
+    private static final int DATA_TYPE_SCAN_MODE = 102;
+    private static final int UID = 1234;
+    private static final int PID = 5678;
+    private static final String PACKAGE_NAME = "android.nearby.test";
+    private static final int RSSI = -60;
+    @Mock
+    Injector mInjector;
+    @Mock
+    Context mContext;
+    @Mock
+    AppOpsManager mAppOpsManager;
+    @Mock
+    BleDiscoveryProvider mBleDiscoveryProvider;
+    @Mock
+    ChreDiscoveryProvider mChreDiscoveryProvider;
+    @Mock
+    DiscoveryProviderController mBluetoothController;
+    @Mock
+    DiscoveryProviderController mChreController;
+    @Mock
+    IScanListener mScanListener;
+    @Mock
+    CallerIdentity mCallerIdentity;
+    @Mock
+    IBinder mIBinder;
+    private Executor mExecutor;
+    private DiscoveryProviderManager mDiscoveryProviderManager;
+
+    private static PresenceScanFilter getPresenceScanFilter() {
+        final byte[] secretId = new byte[]{1, 2, 3, 4};
+        final byte[] authenticityKey = new byte[]{0, 1, 1, 1};
+        final byte[] publicKey = new byte[]{1, 1, 2, 2};
+        final byte[] encryptedMetadata = new byte[]{1, 2, 3, 4, 5};
+        final byte[] metadataEncryptionKeyTag = new byte[]{1, 1, 3, 4, 5};
+
+        PublicCredential credential = new PublicCredential.Builder(
+                secretId, authenticityKey, publicKey, encryptedMetadata, metadataEncryptionKeyTag)
+                .setIdentityType(IDENTITY_TYPE_PRIVATE)
+                .build();
+
+        final int action = 123;
+        return new PresenceScanFilter.Builder()
+                .addCredential(credential)
+                .setMaxPathLoss(RSSI)
+                .addPresenceAction(action)
+                .build();
+    }
+
+    private static PresenceScanFilter getChreOnlyPresenceScanFilter() {
+        final byte[] secretId = new byte[]{1, 2, 3, 4};
+        final byte[] authenticityKey = new byte[]{0, 1, 1, 1};
+        final byte[] publicKey = new byte[]{1, 1, 2, 2};
+        final byte[] encryptedMetadata = new byte[]{1, 2, 3, 4, 5};
+        final byte[] metadataEncryptionKeyTag = new byte[]{1, 1, 3, 4, 5};
+
+        PublicCredential credential = new PublicCredential.Builder(
+                secretId, authenticityKey, publicKey, encryptedMetadata, metadataEncryptionKeyTag)
+                .setIdentityType(IDENTITY_TYPE_PRIVATE)
+                .build();
+
+        final int action = 123;
+        DataElement scanModeElement = new DataElement(DATA_TYPE_SCAN_MODE,
+                new byte[]{SCAN_MODE_CHRE_ONLY});
+        return new PresenceScanFilter.Builder()
+                .addCredential(credential)
+                .setMaxPathLoss(RSSI)
+                .addPresenceAction(action)
+                .addExtendedProperty(scanModeElement)
+                .build();
+    }
+
+    @Before
+    public void setup() {
+        MockitoAnnotations.initMocks(this);
+        mExecutor = Executors.newSingleThreadExecutor();
+        when(mInjector.getAppOpsManager()).thenReturn(mAppOpsManager);
+        when(mBleDiscoveryProvider.getController()).thenReturn(mBluetoothController);
+        when(mChreDiscoveryProvider.getController()).thenReturn(mChreController);
+        when(mScanListener.asBinder()).thenReturn(mIBinder);
+
+        mDiscoveryProviderManager =
+                new DiscoveryProviderManager(mContext, mExecutor, mInjector,
+                        mBleDiscoveryProvider,
+                        mChreDiscoveryProvider);
+        mCallerIdentity = CallerIdentity
+                .forTest(UID, PID, PACKAGE_NAME, /* attributionTag= */ null);
+    }
+
+    @Test
+    public void testOnNearbyDeviceDiscovered() {
+        NearbyDeviceParcelable nearbyDeviceParcelable = new NearbyDeviceParcelable.Builder()
+                .setScanType(SCAN_TYPE_NEARBY_PRESENCE)
+                .build();
+        mDiscoveryProviderManager.onNearbyDeviceDiscovered(nearbyDeviceParcelable);
+    }
+
+    @Test
+    public void testInvalidateProviderScanMode() {
+        mDiscoveryProviderManager.invalidateProviderScanMode();
+    }
+
+    @Test
+    public void testStartProviders_chreOnlyChreAvailable_bleProviderNotStarted() {
+        reset(mBluetoothController);
+        when(mChreDiscoveryProvider.available()).thenReturn(true);
+
+        ScanRequest scanRequest = new ScanRequest.Builder()
+                .setScanType(SCAN_TYPE_NEARBY_PRESENCE)
+                .addScanFilter(getChreOnlyPresenceScanFilter()).build();
+        mDiscoveryProviderManager.registerScanListener(scanRequest, mScanListener, mCallerIdentity);
+
+        Boolean start = mDiscoveryProviderManager.startProviders();
+        verify(mBluetoothController, never()).start();
+        assertThat(start).isTrue();
+    }
+
+    @Test
+    public void testStartProviders_chreOnlyChreAvailable_multipleFilters_bleProviderNotStarted() {
+        reset(mBluetoothController);
+        when(mChreDiscoveryProvider.available()).thenReturn(true);
+
+        ScanRequest scanRequest = new ScanRequest.Builder()
+                .setScanType(SCAN_TYPE_NEARBY_PRESENCE)
+                .addScanFilter(getChreOnlyPresenceScanFilter()).build();
+        mDiscoveryProviderManager.registerScanListener(scanRequest, mScanListener, mCallerIdentity);
+
+        Boolean start = mDiscoveryProviderManager.startProviders();
+        verify(mBluetoothController, never()).start();
+        assertThat(start).isTrue();
+    }
+
+    @Test
+    public void testStartProviders_chreOnlyChreUnavailable_bleProviderNotStarted() {
+        reset(mBluetoothController);
+        when(mChreDiscoveryProvider.available()).thenReturn(false);
+
+        ScanRequest scanRequest = new ScanRequest.Builder()
+                .setScanType(SCAN_TYPE_NEARBY_PRESENCE)
+                .addScanFilter(getChreOnlyPresenceScanFilter()).build();
+        mDiscoveryProviderManager.registerScanListener(scanRequest, mScanListener, mCallerIdentity);
+
+        Boolean start = mDiscoveryProviderManager.startProviders();
+        verify(mBluetoothController, never()).start();
+        assertThat(start).isFalse();
+    }
+
+    @Test
+    public void testStartProviders_notChreOnlyChreAvailable_bleProviderNotStarted() {
+        when(mChreDiscoveryProvider.available()).thenReturn(true);
+        reset(mBluetoothController);
+
+        ScanRequest scanRequest = new ScanRequest.Builder()
+                .setScanType(SCAN_TYPE_NEARBY_PRESENCE)
+                .addScanFilter(getPresenceScanFilter()).build();
+        mDiscoveryProviderManager.registerScanListener(scanRequest, mScanListener, mCallerIdentity);
+
+        Boolean start = mDiscoveryProviderManager.startProviders();
+        verify(mBluetoothController, never()).start();
+        assertThat(start).isTrue();
+    }
+
+    @Test
+    public void testStartProviders_notChreOnlyChreUnavailable_bleProviderStarted() {
+        when(mChreDiscoveryProvider.available()).thenReturn(false);
+        reset(mBluetoothController);
+
+        ScanRequest scanRequest = new ScanRequest.Builder()
+                .setScanType(SCAN_TYPE_NEARBY_PRESENCE)
+                .addScanFilter(getPresenceScanFilter()).build();
+        mDiscoveryProviderManager.registerScanListener(scanRequest, mScanListener, mCallerIdentity);
+
+        Boolean start = mDiscoveryProviderManager.startProviders();
+        verify(mBluetoothController, atLeastOnce()).start();
+        assertThat(start).isTrue();
+    }
+
+    @Test
+    public void testStartProviders_chreOnlyChreUndetermined_bleProviderNotStarted() {
+        when(mChreDiscoveryProvider.available()).thenReturn(null);
+        reset(mBluetoothController);
+
+        ScanRequest scanRequest = new ScanRequest.Builder()
+                .setScanType(SCAN_TYPE_NEARBY_PRESENCE)
+                .addScanFilter(getChreOnlyPresenceScanFilter()).build();
+        mDiscoveryProviderManager.registerScanListener(scanRequest, mScanListener, mCallerIdentity);
+
+        Boolean start = mDiscoveryProviderManager.startProviders();
+        verify(mBluetoothController, never()).start();
+        assertThat(start).isNull();
+    }
+
+    @Test
+    public void testStartProviders_notChreOnlyChreUndetermined_bleProviderStarted() {
+        when(mChreDiscoveryProvider.available()).thenReturn(null);
+        reset(mBluetoothController);
+
+        ScanRequest scanRequest = new ScanRequest.Builder()
+                .setScanType(SCAN_TYPE_NEARBY_PRESENCE)
+                .addScanFilter(getPresenceScanFilter()).build();
+        mDiscoveryProviderManager.registerScanListener(scanRequest, mScanListener, mCallerIdentity);
+
+        Boolean start = mDiscoveryProviderManager.startProviders();
+        verify(mBluetoothController, atLeastOnce()).start();
+        assertThat(start).isTrue();
+    }
+
+    @Test
+    public void test_stopChreProvider_clearFilters() throws Exception {
+        // Cannot use mocked ChreDiscoveryProvider,
+        // so we cannot use class variable mDiscoveryProviderManager
+        DiscoveryProviderManager manager =
+                new DiscoveryProviderManager(mContext, mExecutor, mInjector,
+                        mBleDiscoveryProvider,
+                        new ChreDiscoveryProvider(
+                                mContext,
+                                new ChreCommunication(mInjector, mContext, mExecutor), mExecutor));
+        ScanRequest scanRequest = new ScanRequest.Builder()
+                .setScanType(SCAN_TYPE_NEARBY_PRESENCE)
+                .addScanFilter(getPresenceScanFilter()).build();
+        manager.registerScanListener(scanRequest, mScanListener, mCallerIdentity);
+        manager.startChreProvider(List.of(getPresenceScanFilter()));
+        // This is an asynchronized process. The filters will be set in executor thread. So we need
+        // to wait for some time to get the correct result.
+        Thread.sleep(200);
+
+        assertThat(manager.mChreDiscoveryProvider.getController().isStarted())
+                .isTrue();
+        assertThat(manager.mChreDiscoveryProvider.getFiltersLocked()).isNotNull();
+
+        manager.stopChreProvider();
+        Thread.sleep(200);
+        // The filters should be cleared right after.
+        assertThat(manager.mChreDiscoveryProvider.getController().isStarted())
+                .isFalse();
+        assertThat(manager.mChreDiscoveryProvider.getFiltersLocked()).isEmpty();
+    }
+
+    @Test
+    public void test_restartChreProvider() throws Exception {
+        // Cannot use mocked ChreDiscoveryProvider,
+        // so we cannot use class variable mDiscoveryProviderManager
+        DiscoveryProviderManager manager =
+                new DiscoveryProviderManager(mContext, mExecutor, mInjector,
+                        mBleDiscoveryProvider,
+                        new ChreDiscoveryProvider(
+                                mContext,
+                                new ChreCommunication(mInjector, mContext, mExecutor), mExecutor));
+        ScanRequest scanRequest = new ScanRequest.Builder()
+                .setScanType(SCAN_TYPE_NEARBY_PRESENCE)
+                .addScanFilter(getPresenceScanFilter()).build();
+        manager.registerScanListener(scanRequest, mScanListener, mCallerIdentity);
+
+        manager.startChreProvider(List.of(getPresenceScanFilter()));
+        // This is an asynchronized process. The filters will be set in executor thread. So we need
+        // to wait for some time to get the correct result.
+        Thread.sleep(200);
+
+        assertThat(manager.mChreDiscoveryProvider.getController().isStarted())
+                .isTrue();
+        assertThat(manager.mChreDiscoveryProvider.getFiltersLocked()).isNotNull();
+
+        // We want to make sure quickly restart the provider the filters should
+        // be reset correctly.
+        // See b/255922206, there can be a race condition that filters get cleared because onStop()
+        // get executed after onStart() if they are called from different threads.
+        manager.stopChreProvider();
+        manager.mChreDiscoveryProvider.getController().setProviderScanFilters(
+                List.of(getPresenceScanFilter()));
+        manager.startChreProvider(List.of(getPresenceScanFilter()));
+        Thread.sleep(200);
+        assertThat(manager.mChreDiscoveryProvider.getController().isStarted())
+                .isTrue();
+        assertThat(manager.mChreDiscoveryProvider.getFiltersLocked()).isNotNull();
+
+        // Wait for enough time
+        Thread.sleep(1000);
+
+        assertThat(manager.mChreDiscoveryProvider.getController().isStarted())
+                .isTrue();
+        assertThat(manager.mChreDiscoveryProvider.getFiltersLocked()).isNotNull();
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/managers/ListenerMultiplexerTest.java b/nearby/tests/unit/src/com/android/server/nearby/managers/ListenerMultiplexerTest.java
new file mode 100644
index 0000000..104d762
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/managers/ListenerMultiplexerTest.java
@@ -0,0 +1,302 @@
+/*
+ * 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.nearby.managers;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.MockitoAnnotations.initMocks;
+
+import android.os.IBinder;
+
+import androidx.annotation.NonNull;
+
+import com.android.server.nearby.managers.registration.BinderListenerRegistration;
+
+import com.google.common.util.concurrent.MoreExecutors;
+
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.Collection;
+
+public class ListenerMultiplexerTest {
+
+    @Before
+    public void setUp() {
+        initMocks(this);
+    }
+
+    @Test
+    public void testAdd() {
+        TestMultiplexer multiplexer = new TestMultiplexer();
+
+        Runnable listener = mock(Runnable.class);
+        IBinder binder = mock(IBinder.class);
+        int value = 2;
+        multiplexer.addListener(binder, listener, value);
+
+        synchronized (multiplexer.mMultiplexerLock) {
+            assertThat(multiplexer.mRegistered).isTrue();
+            assertThat(multiplexer.mOnRegisterCalledCount).isEqualTo(1);
+            assertThat(multiplexer.mMergeOperationCount).isEqualTo(1);
+            assertThat(multiplexer.mMergeUpdatedCount).isEqualTo(1);
+            assertThat(multiplexer.mMerged).isEqualTo(value);
+        }
+        Runnable listener2 = mock(Runnable.class);
+        IBinder binder2 = mock(IBinder.class);
+        int value2 = 1;
+        multiplexer.addListener(binder2, listener2, value2);
+        synchronized (multiplexer.mMultiplexerLock) {
+            assertThat(multiplexer.mRegistered).isTrue();
+            assertThat(multiplexer.mOnRegisterCalledCount).isEqualTo(1);
+            assertThat(multiplexer.mMergeOperationCount).isEqualTo(2);
+            assertThat(multiplexer.mMergeUpdatedCount).isEqualTo(1);
+            assertThat(multiplexer.mMerged).isEqualTo(value);
+        }
+    }
+
+    @Test
+    public void testReplace() {
+        TestMultiplexer multiplexer = new TestMultiplexer();
+        Runnable listener = mock(Runnable.class);
+        IBinder binder = mock(IBinder.class);
+        int value = 2;
+        multiplexer.addListener(binder, listener, value);
+        synchronized (multiplexer.mMultiplexerLock) {
+            assertThat(multiplexer.mRegistered).isTrue();
+            assertThat(multiplexer.mOnRegisterCalledCount).isEqualTo(1);
+            assertThat(multiplexer.mMerged).isEqualTo(value);
+        }
+        multiplexer.notifyListeners();
+        verify(listener, times(1)).run();
+        reset(listener);
+
+        // Same key, different value
+        Runnable listener2 = mock(Runnable.class);
+        int value2 = 1;
+        multiplexer.addListener(binder, listener2, value2);
+        synchronized (multiplexer.mMultiplexerLock) {
+            assertThat(multiplexer.mRegistered).isTrue();
+            // Should not be called again
+            assertThat(multiplexer.mOnRegisterCalledCount).isEqualTo(1);
+            assertThat(multiplexer.mOnUnregisterCalledCount).isEqualTo(0);
+            assertThat(multiplexer.mMerged).isEqualTo(value2);
+        }
+        // Run on the new listener
+        multiplexer.notifyListeners();
+        verify(listener, never()).run();
+        verify(listener2, times(1)).run();
+
+        multiplexer.removeRegistration(binder);
+
+        synchronized (multiplexer.mMultiplexerLock) {
+            assertThat(multiplexer.mRegistered).isFalse();
+            assertThat(multiplexer.mOnRegisterCalledCount).isEqualTo(1);
+            assertThat(multiplexer.mOnUnregisterCalledCount).isEqualTo(1);
+            assertThat(multiplexer.mMerged).isEqualTo(Integer.MIN_VALUE);
+        }
+    }
+
+    @Test
+    public void testRemove() {
+        TestMultiplexer multiplexer = new TestMultiplexer();
+        Runnable listener = mock(Runnable.class);
+        IBinder binder = mock(IBinder.class);
+        int value = 2;
+        multiplexer.addListener(binder, listener, value);
+        synchronized (multiplexer.mMultiplexerLock) {
+            assertThat(multiplexer.mRegistered).isTrue();
+            assertThat(multiplexer.mMerged).isEqualTo(value);
+        }
+        multiplexer.notifyListeners();
+        verify(listener, times(1)).run();
+        reset(listener);
+
+        multiplexer.removeRegistration(binder);
+        synchronized (multiplexer.mMultiplexerLock) {
+            assertThat(multiplexer.mRegistered).isFalse();
+            assertThat(multiplexer.mMerged).isEqualTo(Integer.MIN_VALUE);
+        }
+        multiplexer.notifyListeners();
+        verify(listener, never()).run();
+    }
+
+    @Test
+    public void testMergeMultiple() {
+        TestMultiplexer multiplexer = new TestMultiplexer();
+
+        Runnable listener = mock(Runnable.class);
+        IBinder binder = mock(IBinder.class);
+        int value = 2;
+
+        Runnable listener2 = mock(Runnable.class);
+        IBinder binder2 = mock(IBinder.class);
+        int value2 = 1;
+
+        Runnable listener3 = mock(Runnable.class);
+        IBinder binder3 = mock(IBinder.class);
+        int value3 = 5;
+
+        multiplexer.addListener(binder, listener, value);
+        synchronized (multiplexer.mMultiplexerLock) {
+            assertThat(multiplexer.mRegistered).isTrue();
+            assertThat(multiplexer.mOnRegisterCalledCount).isEqualTo(1);
+            assertThat(multiplexer.mMergeOperationCount).isEqualTo(1);
+            assertThat(multiplexer.mMergeUpdatedCount).isEqualTo(1);
+            assertThat(multiplexer.mMerged).isEqualTo(value);
+        }
+        multiplexer.notifyListeners();
+        verify(listener, times(1)).run();
+        verify(listener2, never()).run();
+        verify(listener3, never()).run();
+
+        multiplexer.addListener(binder2, listener2, value2);
+        synchronized (multiplexer.mMultiplexerLock) {
+            assertThat(multiplexer.mRegistered).isTrue();
+            assertThat(multiplexer.mOnRegisterCalledCount).isEqualTo(1);
+            assertThat(multiplexer.mMergeOperationCount).isEqualTo(2);
+            assertThat(multiplexer.mMergeUpdatedCount).isEqualTo(1);
+            assertThat(multiplexer.mMerged).isEqualTo(value);
+        }
+        multiplexer.notifyListeners();
+        verify(listener, times(2)).run();
+        verify(listener2, times(1)).run();
+        verify(listener3, never()).run();
+
+        multiplexer.addListener(binder3, listener3, value3);
+        synchronized (multiplexer.mMultiplexerLock) {
+            assertThat(multiplexer.mRegistered).isTrue();
+            assertThat(multiplexer.mOnRegisterCalledCount).isEqualTo(1);
+            assertThat(multiplexer.mMergeOperationCount).isEqualTo(3);
+            assertThat(multiplexer.mMergeUpdatedCount).isEqualTo(2);
+            assertThat(multiplexer.mMerged).isEqualTo(value3);
+        }
+        multiplexer.notifyListeners();
+        verify(listener, times(3)).run();
+        verify(listener2, times(2)).run();
+        verify(listener3, times(1)).run();
+
+        multiplexer.removeRegistration(binder);
+        synchronized (multiplexer.mMultiplexerLock) {
+            assertThat(multiplexer.mRegistered).isTrue();
+            assertThat(multiplexer.mOnRegisterCalledCount).isEqualTo(1);
+            assertThat(multiplexer.mMergeOperationCount).isEqualTo(4);
+            assertThat(multiplexer.mMergeUpdatedCount).isEqualTo(2);
+            assertThat(multiplexer.mMerged).isEqualTo(value3);
+        }
+        multiplexer.notifyListeners();
+        verify(listener, times(3)).run();
+        verify(listener2, times(3)).run();
+        verify(listener3, times(2)).run();
+
+        multiplexer.removeRegistration(binder3);
+        synchronized (multiplexer.mMultiplexerLock) {
+            assertThat(multiplexer.mRegistered).isTrue();
+            assertThat(multiplexer.mOnRegisterCalledCount).isEqualTo(1);
+            assertThat(multiplexer.mMergeOperationCount).isEqualTo(5);
+            assertThat(multiplexer.mMergeUpdatedCount).isEqualTo(3);
+            assertThat(multiplexer.mMerged).isEqualTo(value2);
+        }
+        multiplexer.notifyListeners();
+        verify(listener, times(3)).run();
+        verify(listener2, times(4)).run();
+        verify(listener3, times(2)).run();
+
+        multiplexer.removeRegistration(binder2);
+        synchronized (multiplexer.mMultiplexerLock) {
+            assertThat(multiplexer.mRegistered).isFalse();
+            assertThat(multiplexer.mOnRegisterCalledCount).isEqualTo(1);
+            assertThat(multiplexer.mMergeOperationCount).isEqualTo(6);
+            assertThat(multiplexer.mMergeUpdatedCount).isEqualTo(4);
+            assertThat(multiplexer.mMerged).isEqualTo(Integer.MIN_VALUE);
+        }
+        multiplexer.notifyListeners();
+        verify(listener, times(3)).run();
+        verify(listener2, times(4)).run();
+        verify(listener3, times(2)).run();
+    }
+
+    private class TestMultiplexer extends
+            ListenerMultiplexer<Runnable, TestMultiplexer.TestListenerRegistration, Integer> {
+        int mOnRegisterCalledCount;
+        int mOnUnregisterCalledCount;
+        boolean mRegistered;
+        private int mMergeOperationCount;
+        private int mMergeUpdatedCount;
+
+        @Override
+        public void onRegister() {
+            mOnRegisterCalledCount++;
+            mRegistered = true;
+        }
+
+        @Override
+        public void onUnregister() {
+            mOnUnregisterCalledCount++;
+            mRegistered = false;
+        }
+
+        @Override
+        public Integer mergeRegistrations(
+                @NonNull Collection<TestListenerRegistration> testListenerRegistrations) {
+            mMergeOperationCount++;
+            int max = Integer.MIN_VALUE;
+            for (TestListenerRegistration registration : testListenerRegistrations) {
+                max = Math.max(max, registration.getValue());
+            }
+            return max;
+        }
+
+        @Override
+        public void onMergedRegistrationsUpdated() {
+            mMergeUpdatedCount++;
+        }
+
+        public void addListener(IBinder binder, Runnable runnable, int value) {
+            TestListenerRegistration registration = new TestListenerRegistration(binder, runnable,
+                    value);
+            putRegistration(binder, registration);
+        }
+
+        public void notifyListeners() {
+            deliverToListeners(registration -> Runnable::run);
+        }
+
+        private class TestListenerRegistration extends BinderListenerRegistration<Runnable> {
+            private final int mValue;
+
+            protected TestListenerRegistration(IBinder binder, Runnable runnable, int value) {
+                super(binder, MoreExecutors.directExecutor(), runnable);
+                mValue = value;
+            }
+
+            @Override
+            public TestMultiplexer getOwner() {
+                return TestMultiplexer.this;
+            }
+
+            public int getValue() {
+                return mValue;
+            }
+        }
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/managers/MergedDiscoveryRequestTest.java b/nearby/tests/unit/src/com/android/server/nearby/managers/MergedDiscoveryRequestTest.java
new file mode 100644
index 0000000..9281e42
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/managers/MergedDiscoveryRequestTest.java
@@ -0,0 +1,104 @@
+/*
+ * 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.nearby.managers;
+
+import static android.nearby.PresenceCredential.IDENTITY_TYPE_PRIVATE;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.nearby.DataElement;
+import android.nearby.PresenceScanFilter;
+import android.nearby.PublicCredential;
+import android.nearby.ScanFilter;
+import android.nearby.ScanRequest;
+import android.util.ArraySet;
+
+import org.junit.Test;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Set;
+
+/**
+ * Unit test for {@link MergedDiscoveryRequest} class.
+ */
+public class MergedDiscoveryRequestTest {
+
+    @Test
+    public void test_addScanType() {
+        MergedDiscoveryRequest.Builder builder = new MergedDiscoveryRequest.Builder();
+        builder.addScanType(ScanRequest.SCAN_TYPE_FAST_PAIR);
+        builder.addScanType(ScanRequest.SCAN_TYPE_NEARBY_PRESENCE);
+        MergedDiscoveryRequest request = builder.build();
+
+        assertThat(request.getScanTypes()).isEqualTo(new ArraySet<>(
+                Arrays.asList(ScanRequest.SCAN_TYPE_FAST_PAIR,
+                        ScanRequest.SCAN_TYPE_NEARBY_PRESENCE)));
+    }
+
+    @Test
+    public void test_addActions() {
+        MergedDiscoveryRequest.Builder builder = new MergedDiscoveryRequest.Builder();
+        builder.addActions(new ArrayList<>(Arrays.asList(1, 2, 3)));
+        builder.addActions(new ArraySet<>(Arrays.asList(2, 3, 4)));
+        builder.addActions(new ArraySet<>(Collections.singletonList(5)));
+
+        MergedDiscoveryRequest request = builder.build();
+        assertThat(request.getActions()).isEqualTo(new ArraySet<>(new Integer[]{1, 2, 3, 4, 5}));
+    }
+
+    @Test
+    public void test_addFilters() {
+        final int rssi = -40;
+        final int action = 123;
+        final byte[] secreteId = new byte[]{1, 2, 3, 4};
+        final byte[] authenticityKey = new byte[]{0, 1, 1, 1};
+        final byte[] publicKey = new byte[]{1, 1, 2, 2};
+        final byte[] encryptedMetadata = new byte[]{1, 2, 3, 4, 5};
+        final byte[] metadataEncryptionKeyTag = new byte[]{1, 1, 3, 4, 5};
+        final int key = 3;
+        final byte[] value = new byte[]{1, 1, 1, 1};
+
+        PublicCredential mPublicCredential = new PublicCredential.Builder(secreteId,
+                authenticityKey, publicKey, encryptedMetadata,
+                metadataEncryptionKeyTag).setIdentityType(IDENTITY_TYPE_PRIVATE).build();
+        PresenceScanFilter scanFilterBuilder = new PresenceScanFilter.Builder().setMaxPathLoss(
+                rssi).addCredential(mPublicCredential).addPresenceAction(
+                action).addExtendedProperty(new DataElement(key, value)).build();
+
+        MergedDiscoveryRequest.Builder builder = new MergedDiscoveryRequest.Builder();
+        builder.addScanFilters(Collections.singleton(scanFilterBuilder));
+        MergedDiscoveryRequest request = builder.build();
+
+        Set<ScanFilter> expectedResult = new ArraySet<>();
+        expectedResult.add(scanFilterBuilder);
+        assertThat(request.getScanFilters()).isEqualTo(expectedResult);
+    }
+
+    @Test
+    public void test_addMedium() {
+        MergedDiscoveryRequest.Builder builder = new MergedDiscoveryRequest.Builder();
+        builder.addMedium(MergedDiscoveryRequest.Medium.BLE);
+        builder.addMedium(MergedDiscoveryRequest.Medium.BLE);
+        MergedDiscoveryRequest request = builder.build();
+
+        Set<Integer> expectedResult = new ArraySet<>();
+        expectedResult.add(MergedDiscoveryRequest.Medium.BLE);
+        assertThat(request.getMediums()).isEqualTo(expectedResult);
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/managers/registration/BinderListenerRegistrationTest.java b/nearby/tests/unit/src/com/android/server/nearby/managers/registration/BinderListenerRegistrationTest.java
new file mode 100644
index 0000000..8814190
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/managers/registration/BinderListenerRegistrationTest.java
@@ -0,0 +1,163 @@
+/*
+ * 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.nearby.managers.registration;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import android.os.IBinder;
+import android.os.RemoteException;
+
+import androidx.annotation.NonNull;
+
+import com.android.server.nearby.managers.ListenerMultiplexer;
+
+import com.google.common.util.concurrent.MoreExecutors;
+
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.Collection;
+
+/**
+ * Unit test for {@link BinderListenerRegistration} class.
+ */
+public class BinderListenerRegistrationTest {
+    private TestMultiplexer mMultiplexer;
+    private boolean mOnRegisterCalled;
+    private boolean mOnUnRegisterCalled;
+
+    @Before
+    public void setUp() {
+        mMultiplexer = new TestMultiplexer();
+    }
+
+    @Test
+    public void test_addAndRemove() throws RemoteException {
+        Runnable listener = mock(Runnable.class);
+        IBinder binder = mock(IBinder.class);
+        int value = 2;
+        BinderListenerRegistration<Runnable> registration = mMultiplexer.addListener(binder,
+                listener, value);
+        // First element, onRegister should be called
+        assertThat(mOnRegisterCalled).isTrue();
+        verify(binder, times(1)).linkToDeath(any(), anyInt());
+        mMultiplexer.notifyListeners();
+        verify(listener, times(1)).run();
+        synchronized (mMultiplexer.mMultiplexerLock) {
+            assertThat(mMultiplexer.mMerged).isEqualTo(value);
+        }
+        reset(listener);
+
+        Runnable listener2 = mock(Runnable.class);
+        IBinder binder2 = mock(IBinder.class);
+        int value2 = 1;
+        BinderListenerRegistration<Runnable> registration2 = mMultiplexer.addListener(binder2,
+                listener2, value2);
+        verify(binder2, times(1)).linkToDeath(any(), anyInt());
+        mMultiplexer.notifyListeners();
+        verify(listener2, times(1)).run();
+        synchronized (mMultiplexer.mMultiplexerLock) {
+            assertThat(mMultiplexer.mMerged).isEqualTo(value);
+        }
+        reset(listener);
+        reset(listener2);
+
+        registration2.remove();
+        verify(binder2, times(1)).unlinkToDeath(any(), anyInt());
+        // Remove one element, onUnregister should NOT be called
+        assertThat(mOnUnRegisterCalled).isFalse();
+        mMultiplexer.notifyListeners();
+        verify(listener, times(1)).run();
+        synchronized (mMultiplexer.mMultiplexerLock) {
+            assertThat(mMultiplexer.mMerged).isEqualTo(value);
+        }
+        reset(listener);
+        reset(listener2);
+
+        registration.remove();
+        verify(binder, times(1)).unlinkToDeath(any(), anyInt());
+        // Remove all elements, onUnregister should NOT be called
+        assertThat(mOnUnRegisterCalled).isTrue();
+        synchronized (mMultiplexer.mMultiplexerLock) {
+            assertThat(mMultiplexer.mMerged).isEqualTo(Integer.MIN_VALUE);
+        }
+    }
+
+    private class TestMultiplexer extends
+            ListenerMultiplexer<Runnable, TestMultiplexer.TestListenerRegistration, Integer> {
+        @Override
+        public void onRegister() {
+            mOnRegisterCalled = true;
+        }
+
+        @Override
+        public void onUnregister() {
+            mOnUnRegisterCalled = true;
+        }
+
+        @Override
+        public Integer mergeRegistrations(
+                @NonNull Collection<TestListenerRegistration> testListenerRegistrations) {
+            int max = Integer.MIN_VALUE;
+            for (TestListenerRegistration registration : testListenerRegistrations) {
+                max = Math.max(max, registration.getValue());
+            }
+            return max;
+        }
+
+        @Override
+        public void onMergedRegistrationsUpdated() {
+        }
+
+        public BinderListenerRegistration<Runnable> addListener(IBinder binder, Runnable runnable,
+                int value) {
+            TestListenerRegistration registration = new TestListenerRegistration(binder, runnable,
+                    value);
+            putRegistration(binder, registration);
+            return registration;
+        }
+
+        public void notifyListeners() {
+            deliverToListeners(registration -> Runnable::run);
+        }
+
+        private class TestListenerRegistration extends BinderListenerRegistration<Runnable> {
+            private final int mValue;
+
+            protected TestListenerRegistration(IBinder binder, Runnable runnable, int value) {
+                super(binder, MoreExecutors.directExecutor(), runnable);
+                mValue = value;
+            }
+
+            @Override
+            public TestMultiplexer getOwner() {
+                return TestMultiplexer.this;
+            }
+
+            public int getValue() {
+                return mValue;
+            }
+        }
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/managers/registration/DiscoveryRegistrationTest.java b/nearby/tests/unit/src/com/android/server/nearby/managers/registration/DiscoveryRegistrationTest.java
new file mode 100644
index 0000000..03c4f75
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/managers/registration/DiscoveryRegistrationTest.java
@@ -0,0 +1,252 @@
+/*
+ * 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.nearby.managers.registration;
+
+import static android.nearby.PresenceCredential.IDENTITY_TYPE_PRIVATE;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.mockito.MockitoAnnotations.initMocks;
+
+import android.app.AppOpsManager;
+import android.nearby.DataElement;
+import android.nearby.IScanListener;
+import android.nearby.NearbyDeviceParcelable;
+import android.nearby.PresenceScanFilter;
+import android.nearby.PublicCredential;
+import android.nearby.ScanCallback;
+import android.nearby.ScanFilter;
+import android.nearby.ScanRequest;
+import android.os.IBinder;
+import android.util.ArraySet;
+
+import androidx.annotation.NonNull;
+
+import com.android.server.nearby.managers.ListenerMultiplexer;
+import com.android.server.nearby.managers.MergedDiscoveryRequest;
+import com.android.server.nearby.util.identity.CallerIdentity;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mock;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.Executors;
+
+/**
+ * Unit test for {@link DiscoveryRegistration} class.
+ */
+public class DiscoveryRegistrationTest {
+    private static final int RSSI = -40;
+    private static final int ACTION = 123;
+    private static final byte[] SECRETE_ID = new byte[]{1, 2, 3, 4};
+    private static final byte[] AUTHENTICITY_KEY = new byte[]{0, 1, 1, 1};
+    private static final byte[] PUBLIC_KEY = new byte[]{1, 1, 2, 2};
+    private static final byte[] ENCRYPTED_METADATA = new byte[]{1, 2, 3, 4, 5};
+    private static final byte[] METADATA_ENCRYPTION_KEY_TAG = new byte[]{1, 1, 3, 4, 5};
+    private static final int KEY = 3;
+    private static final byte[] VALUE = new byte[]{1, 1, 1, 1};
+    private final PublicCredential mPublicCredential = new PublicCredential.Builder(SECRETE_ID,
+            AUTHENTICITY_KEY, PUBLIC_KEY, ENCRYPTED_METADATA,
+            METADATA_ENCRYPTION_KEY_TAG).setIdentityType(IDENTITY_TYPE_PRIVATE).build();
+    private final PresenceScanFilter mFilter = new PresenceScanFilter.Builder().setMaxPathLoss(
+            50).addCredential(mPublicCredential).addPresenceAction(ACTION).addExtendedProperty(
+            new DataElement(KEY, VALUE)).build();
+    private DiscoveryRegistration mDiscoveryRegistration;
+    private ScanRequest mScanRequest;
+    private TestDiscoveryManager mOwner;
+    private Object mMultiplexLock;
+    @Mock
+    private IScanListener mCallback;
+    @Mock
+    private CallerIdentity mIdentity;
+    @Mock
+    private AppOpsManager mAppOpsManager;
+    @Mock
+    private IBinder mBinder;
+
+    @Before
+    public void setUp() {
+        initMocks(this);
+        when(mCallback.asBinder()).thenReturn(mBinder);
+        when(mAppOpsManager.noteOp(eq("android:bluetooth_scan"), eq(0), eq(null), eq(null),
+                eq(null))).thenReturn(AppOpsManager.MODE_ALLOWED);
+
+        mOwner = new TestDiscoveryManager();
+        mMultiplexLock = new Object();
+        mScanRequest = new ScanRequest.Builder().setScanType(
+                ScanRequest.SCAN_TYPE_NEARBY_PRESENCE).addScanFilter(mFilter).build();
+        mDiscoveryRegistration = new DiscoveryRegistration(mOwner, mScanRequest, mCallback,
+                Executors.newSingleThreadExecutor(), mIdentity, mMultiplexLock, mAppOpsManager);
+    }
+
+    @Test
+    public void test_getScanRequest() {
+        assertThat(mDiscoveryRegistration.getScanRequest()).isEqualTo(mScanRequest);
+    }
+
+    @Test
+    public void test_getActions() {
+        Set<Integer> result = new ArraySet<>();
+        result.add(ACTION);
+        assertThat(mDiscoveryRegistration.getActions()).isEqualTo(result);
+    }
+
+    @Test
+    public void test_getOwner() {
+        assertThat(mDiscoveryRegistration.getOwner()).isEqualTo(mOwner);
+    }
+
+    @Test
+    public void test_getPresenceScanFilters() {
+        Set<ScanFilter> result = new ArraySet<>();
+        result.add(mFilter);
+        assertThat(mDiscoveryRegistration.getPresenceScanFilters()).isEqualTo(result);
+    }
+
+    @Test
+    public void test_presenceFilterMatches_match() {
+        NearbyDeviceParcelable device = new NearbyDeviceParcelable.Builder().setDeviceId(
+                123).setName("test").setTxPower(RSSI + 1).setRssi(RSSI).setScanType(
+                ScanRequest.SCAN_TYPE_NEARBY_PRESENCE).setAction(ACTION).setEncryptionKeyTag(
+                METADATA_ENCRYPTION_KEY_TAG).build();
+        assertThat(DiscoveryRegistration.presenceFilterMatches(device, List.of(mFilter))).isTrue();
+    }
+
+    @Test
+    public void test_presenceFilterMatches_emptyFilter() {
+        NearbyDeviceParcelable device = new NearbyDeviceParcelable.Builder().setDeviceId(
+                123).setName("test").setScanType(ScanRequest.SCAN_TYPE_NEARBY_PRESENCE).build();
+        assertThat(DiscoveryRegistration.presenceFilterMatches(device, List.of())).isTrue();
+    }
+
+    @Test
+    public void test_presenceFilterMatches_actionNotMatch() {
+        NearbyDeviceParcelable device = new NearbyDeviceParcelable.Builder().setDeviceId(
+                12).setName("test").setRssi(RSSI).setScanType(
+                ScanRequest.SCAN_TYPE_NEARBY_PRESENCE).setAction(5).setEncryptionKeyTag(
+                METADATA_ENCRYPTION_KEY_TAG).build();
+        assertThat(DiscoveryRegistration.presenceFilterMatches(device, List.of(mFilter))).isFalse();
+    }
+
+    @Test
+    public void test_onDiscoveredOnUpdatedCalled() throws Exception {
+        final long deviceId = 122;
+        NearbyDeviceParcelable.Builder builder = new NearbyDeviceParcelable.Builder().setDeviceId(
+                deviceId).setName("test").setTxPower(RSSI + 1).setRssi(RSSI).setScanType(
+                ScanRequest.SCAN_TYPE_NEARBY_PRESENCE).setAction(ACTION).setEncryptionKeyTag(
+                METADATA_ENCRYPTION_KEY_TAG);
+        runOperation(mDiscoveryRegistration.onNearbyDeviceDiscovered(builder.build()));
+
+        verify(mCallback, times(1)).onDiscovered(eq(builder.build()));
+        verify(mCallback, never()).onUpdated(any());
+        verify(mCallback, never()).onLost(any());
+        verify(mCallback, never()).onError(anyInt());
+        assertThat(mDiscoveryRegistration.getDiscoveryOnLostAlarms().get(deviceId)).isNotNull();
+        reset(mCallback);
+
+        // Update RSSI
+        runOperation(
+                mDiscoveryRegistration.onNearbyDeviceDiscovered(builder.setRssi(RSSI - 1).build()));
+        verify(mCallback, never()).onDiscovered(any());
+        verify(mCallback, times(1)).onUpdated(eq(builder.build()));
+        verify(mCallback, never()).onLost(any());
+        verify(mCallback, never()).onError(anyInt());
+        assertThat(mDiscoveryRegistration.getDiscoveryOnLostAlarms().get(deviceId)).isNotNull();
+    }
+
+    @Test
+    public void test_onLost() throws Exception {
+        final long deviceId = 123;
+        NearbyDeviceParcelable device = new NearbyDeviceParcelable.Builder().setDeviceId(
+                deviceId).setName("test").setTxPower(RSSI + 1).setRssi(RSSI).setScanType(
+                ScanRequest.SCAN_TYPE_NEARBY_PRESENCE).setAction(ACTION).setEncryptionKeyTag(
+                METADATA_ENCRYPTION_KEY_TAG).build();
+        runOperation(mDiscoveryRegistration.onNearbyDeviceDiscovered(device));
+        assertThat(mDiscoveryRegistration.getDiscoveryOnLostAlarms().get(deviceId)).isNotNull();
+        verify(mCallback, times(1)).onDiscovered(eq(device));
+        verify(mCallback, never()).onUpdated(any());
+        verify(mCallback, never()).onError(anyInt());
+        verify(mCallback, never()).onLost(any());
+        reset(mCallback);
+
+        runOperation(mDiscoveryRegistration.reportDeviceLost(device));
+
+        assertThat(mDiscoveryRegistration.getDiscoveryOnLostAlarms().get(deviceId)).isNull();
+        verify(mCallback, never()).onDiscovered(eq(device));
+        verify(mCallback, never()).onUpdated(any());
+        verify(mCallback, never()).onError(anyInt());
+        verify(mCallback, times(1)).onLost(eq(device));
+    }
+
+    @Test
+    public void test_onError() throws Exception {
+        AppOpsManager manager = mock(AppOpsManager.class);
+        when(manager.noteOp(eq("android:bluetooth_scan"), eq(0), eq(null), eq(null),
+                eq(null))).thenReturn(AppOpsManager.MODE_IGNORED);
+
+        DiscoveryRegistration r = new DiscoveryRegistration(mOwner, mScanRequest, mCallback,
+                Executors.newSingleThreadExecutor(), mIdentity, mMultiplexLock, manager);
+
+        NearbyDeviceParcelable device = new NearbyDeviceParcelable.Builder().setDeviceId(
+                123).setName("test").setTxPower(RSSI + 1).setRssi(RSSI).setScanType(
+                ScanRequest.SCAN_TYPE_NEARBY_PRESENCE).setAction(ACTION).setEncryptionKeyTag(
+                METADATA_ENCRYPTION_KEY_TAG).build();
+        runOperation(r.onNearbyDeviceDiscovered(device));
+
+        verify(mCallback, never()).onDiscovered(any());
+        verify(mCallback, never()).onUpdated(any());
+        verify(mCallback, never()).onLost(any());
+        verify(mCallback, times(1)).onError(eq(ScanCallback.ERROR_PERMISSION_DENIED));
+    }
+
+    private void runOperation(BinderListenerRegistration.ListenerOperation<IScanListener> operation)
+            throws Exception {
+        if (operation == null) {
+            return;
+        }
+        operation.onScheduled(false);
+        operation.operate(mCallback);
+        operation.onComplete(/* success= */ true);
+    }
+
+    private static class TestDiscoveryManager extends
+            ListenerMultiplexer<IScanListener, DiscoveryRegistration, MergedDiscoveryRequest> {
+
+        @Override
+        public MergedDiscoveryRequest mergeRegistrations(
+                @NonNull Collection<DiscoveryRegistration> discoveryRegistrations) {
+            return null;
+        }
+
+        @Override
+        public void onMergedRegistrationsUpdated() {
+
+        }
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/presence/DataElementHeaderTest.java b/nearby/tests/unit/src/com/android/server/nearby/presence/DataElementHeaderTest.java
new file mode 100644
index 0000000..e186709
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/presence/DataElementHeaderTest.java
@@ -0,0 +1,179 @@
+/*
+ * 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.nearby.presence;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertThrows;
+
+import android.nearby.BroadcastRequest;
+
+import org.junit.Test;
+
+import java.util.List;
+
+/**
+ * Unit test for {@link DataElementHeader}.
+ */
+public class DataElementHeaderTest {
+
+    private static final int VERSION = BroadcastRequest.PRESENCE_VERSION_V1;
+
+    @Test
+    public void test_illegalLength() {
+        assertThrows(IllegalArgumentException.class,
+                () -> new DataElementHeader(VERSION, 12, 128));
+    }
+
+    @Test
+    public void test_singeByteConversion() {
+        DataElementHeader header = new DataElementHeader(VERSION, 12, 3);
+        byte[] bytes = header.toBytes();
+        assertThat(bytes).isEqualTo(new byte[]{(byte) 0b00111100});
+
+        DataElementHeader afterConversionHeader = DataElementHeader.fromBytes(VERSION, bytes);
+        assertThat(afterConversionHeader.getDataLength()).isEqualTo(3);
+        assertThat(afterConversionHeader.getDataType()).isEqualTo(12);
+    }
+
+    @Test
+    public void test_multipleBytesConversion() {
+        DataElementHeader header = new DataElementHeader(VERSION, 6, 100);
+        DataElementHeader afterConversionHeader =
+                DataElementHeader.fromBytes(VERSION, header.toBytes());
+        assertThat(afterConversionHeader.getDataLength()).isEqualTo(100);
+        assertThat(afterConversionHeader.getDataType()).isEqualTo(6);
+    }
+
+    @Test
+    public void test_fromBytes() {
+        // Single byte case.
+        byte[] singleByte = new byte[]{(byte) 0b01011101};
+        DataElementHeader singeByteHeader = DataElementHeader.fromBytes(VERSION, singleByte);
+        assertThat(singeByteHeader.getDataLength()).isEqualTo(5);
+        assertThat(singeByteHeader.getDataType()).isEqualTo(13);
+
+        // Two bytes case.
+        byte[] twoBytes = new byte[]{(byte) 0b11011101, (byte) 0b01011101};
+        DataElementHeader twoBytesHeader = DataElementHeader.fromBytes(VERSION, twoBytes);
+        assertThat(twoBytesHeader.getDataLength()).isEqualTo(93);
+        assertThat(twoBytesHeader.getDataType()).isEqualTo(93);
+
+        // Three bytes case.
+        byte[] threeBytes = new byte[]{(byte) 0b11011101, (byte) 0b11111111, (byte) 0b01011101};
+        DataElementHeader threeBytesHeader = DataElementHeader.fromBytes(VERSION, threeBytes);
+        assertThat(threeBytesHeader.getDataLength()).isEqualTo(93);
+        assertThat(threeBytesHeader.getDataType()).isEqualTo(16349);
+
+        // Four bytes case.
+        byte[] fourBytes = new byte[]{
+                (byte) 0b11011101, (byte) 0b11111111, (byte) 0b11111111, (byte) 0b01011101};
+
+        DataElementHeader fourBytesHeader = DataElementHeader.fromBytes(VERSION, fourBytes);
+        assertThat(fourBytesHeader.getDataLength()).isEqualTo(93);
+        assertThat(fourBytesHeader.getDataType()).isEqualTo(2097117);
+    }
+
+    @Test
+    public void test_fromBytesIllegal_singleByte() {
+        assertThrows(IllegalArgumentException.class,
+                () -> DataElementHeader.fromBytes(VERSION, new byte[]{(byte) 0b11011101}));
+    }
+
+    @Test
+    public void test_fromBytesIllegal_twoBytes_wrongFirstByte() {
+        assertThrows(IllegalArgumentException.class,
+                () -> DataElementHeader.fromBytes(VERSION,
+                        new byte[]{(byte) 0b01011101, (byte) 0b01011101}));
+    }
+
+    @Test
+    public void test_fromBytesIllegal_twoBytes_wrongLastByte() {
+        assertThrows(IllegalArgumentException.class,
+                () -> DataElementHeader.fromBytes(VERSION,
+                        new byte[]{(byte) 0b11011101, (byte) 0b11011101}));
+    }
+
+    @Test
+    public void test_fromBytesIllegal_threeBytes() {
+        assertThrows(IllegalArgumentException.class,
+                () -> DataElementHeader.fromBytes(VERSION,
+                        new byte[]{(byte) 0b11011101, (byte) 0b11011101, (byte) 0b11011101}));
+    }
+
+    @Test
+    public void test_multipleBytesConversion_largeNumber() {
+        DataElementHeader header = new DataElementHeader(VERSION, 22213546, 66);
+        DataElementHeader afterConversionHeader =
+                DataElementHeader.fromBytes(VERSION, header.toBytes());
+        assertThat(afterConversionHeader.getDataLength()).isEqualTo(66);
+        assertThat(afterConversionHeader.getDataType()).isEqualTo(22213546);
+    }
+
+    @Test
+    public void test_isExtending() {
+        assertThat(DataElementHeader.isExtending((byte) 0b10000100)).isTrue();
+        assertThat(DataElementHeader.isExtending((byte) 0b01110100)).isFalse();
+        assertThat(DataElementHeader.isExtending((byte) 0b00000000)).isFalse();
+    }
+
+    @Test
+    public void test_convertTag() {
+        assertThat(DataElementHeader.convertTag(true)).isEqualTo((byte) 128);
+        assertThat(DataElementHeader.convertTag(false)).isEqualTo(0);
+    }
+
+    @Test
+    public void test_getHeaderValue() {
+        assertThat(DataElementHeader.getHeaderValue((byte) 0b10000100)).isEqualTo(4);
+        assertThat(DataElementHeader.getHeaderValue((byte) 0b00000100)).isEqualTo(4);
+        assertThat(DataElementHeader.getHeaderValue((byte) 0b11010100)).isEqualTo(84);
+        assertThat(DataElementHeader.getHeaderValue((byte) 0b01010100)).isEqualTo(84);
+    }
+
+    @Test
+    public void test_convertTypeMultipleIntList() {
+        List<Byte> list = DataElementHeader.convertTypeMultipleBytes(128);
+        assertThat(list.size()).isEqualTo(2);
+        assertThat(list.get(0)).isEqualTo((byte) 0b10000001);
+        assertThat(list.get(1)).isEqualTo((byte) 0b00000000);
+
+        List<Byte> list2 = DataElementHeader.convertTypeMultipleBytes(10);
+        assertThat(list2.size()).isEqualTo(1);
+        assertThat(list2.get(0)).isEqualTo((byte) 0b00001010);
+
+        List<Byte> list3 = DataElementHeader.convertTypeMultipleBytes(5242398);
+        assertThat(list3.size()).isEqualTo(4);
+        assertThat(list3.get(0)).isEqualTo((byte) 0b10000010);
+        assertThat(list3.get(1)).isEqualTo((byte) 0b10111111);
+        assertThat(list3.get(2)).isEqualTo((byte) 0b11111100);
+        assertThat(list3.get(3)).isEqualTo((byte) 0b00011110);
+    }
+
+    @Test
+    public void test_getTypeMultipleBytes() {
+        byte[] inputBytes = new byte[]{(byte) 0b11011000, (byte) 0b10000000, (byte) 0b00001001};
+        // 0b101100000000000001001
+        assertThat(DataElementHeader.getTypeMultipleBytes(inputBytes)).isEqualTo(1441801);
+
+        byte[] inputBytes2 = new byte[]{(byte) 0b00010010};
+        assertThat(DataElementHeader.getTypeMultipleBytes(inputBytes2)).isEqualTo(18);
+
+        byte[] inputBytes3 = new byte[]{(byte) 0b10000001, (byte) 0b00000000};
+        assertThat(DataElementHeader.getTypeMultipleBytes(inputBytes3)).isEqualTo(128);
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/presence/ExtendedAdvertisementTest.java b/nearby/tests/unit/src/com/android/server/nearby/presence/ExtendedAdvertisementTest.java
new file mode 100644
index 0000000..895df69
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/presence/ExtendedAdvertisementTest.java
@@ -0,0 +1,261 @@
+/*
+ * 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.nearby.presence;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.nearby.BroadcastRequest;
+import android.nearby.DataElement;
+import android.nearby.PresenceBroadcastRequest;
+import android.nearby.PresenceCredential;
+import android.nearby.PrivateCredential;
+import android.nearby.PublicCredential;
+
+import com.android.server.nearby.util.encryption.CryptorImpIdentityV1;
+import com.android.server.nearby.util.encryption.CryptorImpV1;
+
+import org.junit.Before;
+import org.junit.Test;
+
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+public class ExtendedAdvertisementTest {
+    private static final int IDENTITY_TYPE = PresenceCredential.IDENTITY_TYPE_PRIVATE;
+    private static final int DATA_TYPE_MODEL_ID = 7;
+    private static final int DATA_TYPE_BLE_ADDRESS = 101;
+    private static final int DATA_TYPE_PUBLIC_IDENTITY = 3;
+    private static final byte[] MODE_ID_DATA =
+            new byte[]{2, 1, 30, 2, 10, -16, 6, 22, 44, -2, -86, -69, -52};
+    private static final byte[] BLE_ADDRESS = new byte[]{124, 4, 56, 60, 120, -29, -90};
+    private static final DataElement MODE_ID_ADDRESS_ELEMENT =
+            new DataElement(DATA_TYPE_MODEL_ID, MODE_ID_DATA);
+    private static final DataElement BLE_ADDRESS_ELEMENT =
+            new DataElement(DATA_TYPE_BLE_ADDRESS, BLE_ADDRESS);
+
+    private static final byte[] IDENTITY =
+            new byte[]{1, 2, 3, 4, 1, 2, 3, 4, 1, 2, 3, 4, 1, 2, 3, 4};
+    private static final int MEDIUM_TYPE_BLE = 0;
+    private static final byte[] SALT = {2, 3};
+    private static final int PRESENCE_ACTION_1 = 1;
+    private static final int PRESENCE_ACTION_2 = 2;
+
+    private static final byte[] SECRET_ID = new byte[]{1, 2, 3, 4};
+    private static final byte[] AUTHENTICITY_KEY =
+            new byte[]{-97, 10, 107, -86, 25, 65, -54, -95, -72, 59, 54, 93, 9, 3, -24, -88};
+    private static final byte[] PUBLIC_KEY =
+            new byte[] {
+                    48, 89, 48, 19, 6, 7, 42, -122, 72, -50, 61, 2, 1, 6, 8, 42, -122, 72, -50, 61,
+                    66, 0, 4, -56, -39, -92, 69, 0, 52, 23, 67, 83, -14, 75, 52, -14, -5, -41, 48,
+                    -83, 31, 42, -39, 102, -13, 22, -73, -73, 86, 30, -96, -84, -13, 4, 122, 104,
+                    -65, 64, 91, -109, -45, -35, -56, 55, -79, 47, -85, 27, -96, -119, -82, -80,
+                    123, 41, -119, -25, 1, -112, 112
+            };
+    private static final byte[] ENCRYPTED_METADATA_BYTES =
+            new byte[] {
+                    -44, -25, -95, -124, -7, 90, 116, -8, 7, -120, -23, -22, -106, -44, -19, 61,
+                    -18, 39, 29, 78, 108, -11, -39, 85, -30, 64, -99, 102, 65, 37, -42, 114, -37,
+                    88, -112, 8, -75, -53, 23, -16, -104, 67, 49, 48, -53, 73, -109, 44, -23, -11,
+                    -118, -61, -37, -104, 60, 105, 115, 1, 56, -89, -107, -45, -116, -1, -25, 84,
+                    -19, -128, 81, 11, 92, 77, -58, 82, 122, 123, 31, -87, -57, 70, 23, -81, 7, 2,
+                    -114, -83, 74, 124, -68, -98, 47, 91, 9, 48, -67, 41, -7, -97, 78, 66, -65, 58,
+                    -4, -46, -30, -85, -50, 100, 46, -66, -128, 7, 66, 9, 88, 95, 12, -13, 81, -91,
+            };
+    private static final byte[] METADATA_ENCRYPTION_KEY_TAG =
+            new byte[] {-126, -104, 1, -1, 26, -46, -68, -86};
+    private static final String DEVICE_NAME = "test_device";
+
+    private PresenceBroadcastRequest.Builder mBuilder;
+    private PrivateCredential mPrivateCredential;
+    private PublicCredential mPublicCredential;
+
+    @Before
+    public void setUp() {
+        mPrivateCredential =
+                new PrivateCredential.Builder(SECRET_ID, AUTHENTICITY_KEY, IDENTITY, DEVICE_NAME)
+                        .setIdentityType(PresenceCredential.IDENTITY_TYPE_PRIVATE)
+                        .build();
+        mPublicCredential =
+                new PublicCredential.Builder(SECRET_ID, AUTHENTICITY_KEY, PUBLIC_KEY,
+                        ENCRYPTED_METADATA_BYTES, METADATA_ENCRYPTION_KEY_TAG)
+                        .build();
+        mBuilder =
+                new PresenceBroadcastRequest.Builder(Collections.singletonList(MEDIUM_TYPE_BLE),
+                        SALT, mPrivateCredential)
+                        .setVersion(BroadcastRequest.PRESENCE_VERSION_V1)
+                        .addAction(PRESENCE_ACTION_1)
+                        .addAction(PRESENCE_ACTION_2)
+                        .addExtendedProperty(new DataElement(DATA_TYPE_BLE_ADDRESS, BLE_ADDRESS))
+                        .addExtendedProperty(new DataElement(DATA_TYPE_MODEL_ID, MODE_ID_DATA));
+    }
+
+    @Test
+    public void test_createFromRequest() {
+        ExtendedAdvertisement originalAdvertisement = ExtendedAdvertisement.createFromRequest(
+                mBuilder.build());
+
+        assertThat(originalAdvertisement.getActions())
+                .containsExactly(PRESENCE_ACTION_1, PRESENCE_ACTION_2);
+        assertThat(originalAdvertisement.getIdentity()).isEqualTo(IDENTITY);
+        assertThat(originalAdvertisement.getIdentityType()).isEqualTo(IDENTITY_TYPE);
+        assertThat(originalAdvertisement.getLength()).isEqualTo(66);
+        assertThat(originalAdvertisement.getVersion()).isEqualTo(
+                BroadcastRequest.PRESENCE_VERSION_V1);
+        assertThat(originalAdvertisement.getSalt()).isEqualTo(SALT);
+        assertThat(originalAdvertisement.getDataElements())
+                .containsExactly(MODE_ID_ADDRESS_ELEMENT, BLE_ADDRESS_ELEMENT);
+    }
+
+    @Test
+    public void test_createFromRequest_encodeAndDecode() {
+        ExtendedAdvertisement originalAdvertisement = ExtendedAdvertisement.createFromRequest(
+                mBuilder.build());
+
+        byte[] generatedBytes = originalAdvertisement.toBytes();
+
+        ExtendedAdvertisement newAdvertisement =
+                ExtendedAdvertisement.fromBytes(generatedBytes, mPublicCredential);
+
+        assertThat(newAdvertisement.getActions())
+                .containsExactly(PRESENCE_ACTION_1, PRESENCE_ACTION_2);
+        assertThat(newAdvertisement.getIdentity()).isEqualTo(IDENTITY);
+        assertThat(newAdvertisement.getIdentityType()).isEqualTo(IDENTITY_TYPE);
+        assertThat(newAdvertisement.getLength()).isEqualTo(66);
+        assertThat(newAdvertisement.getVersion()).isEqualTo(
+                BroadcastRequest.PRESENCE_VERSION_V1);
+        assertThat(newAdvertisement.getSalt()).isEqualTo(SALT);
+        assertThat(newAdvertisement.getDataElements())
+                .containsExactly(MODE_ID_ADDRESS_ELEMENT, BLE_ADDRESS_ELEMENT);
+    }
+
+    @Test
+    public void test_createFromRequest_invalidParameter() {
+        // invalid version
+        mBuilder.setVersion(BroadcastRequest.PRESENCE_VERSION_V0);
+        assertThat(ExtendedAdvertisement.createFromRequest(mBuilder.build())).isNull();
+
+        // invalid salt
+        PresenceBroadcastRequest.Builder builder =
+                new PresenceBroadcastRequest.Builder(Collections.singletonList(MEDIUM_TYPE_BLE),
+                        new byte[]{1, 2, 3}, mPrivateCredential)
+                        .setVersion(BroadcastRequest.PRESENCE_VERSION_V1)
+                        .addAction(PRESENCE_ACTION_1);
+        assertThat(ExtendedAdvertisement.createFromRequest(builder.build())).isNull();
+
+        // invalid identity
+        PrivateCredential privateCredential =
+                new PrivateCredential.Builder(SECRET_ID,
+                        AUTHENTICITY_KEY, new byte[]{1, 2, 3, 4}, DEVICE_NAME)
+                        .setIdentityType(PresenceCredential.IDENTITY_TYPE_PRIVATE)
+                        .build();
+        PresenceBroadcastRequest.Builder builder2 =
+                new PresenceBroadcastRequest.Builder(Collections.singletonList(MEDIUM_TYPE_BLE),
+                        new byte[]{1, 2, 3}, privateCredential)
+                        .setVersion(BroadcastRequest.PRESENCE_VERSION_V1)
+                        .addAction(PRESENCE_ACTION_1);
+        assertThat(ExtendedAdvertisement.createFromRequest(builder2.build())).isNull();
+
+        // empty action
+        PresenceBroadcastRequest.Builder builder3 =
+                new PresenceBroadcastRequest.Builder(Collections.singletonList(MEDIUM_TYPE_BLE),
+                        SALT, mPrivateCredential)
+                        .setVersion(BroadcastRequest.PRESENCE_VERSION_V1);
+        assertThat(ExtendedAdvertisement.createFromRequest(builder3.build())).isNull();
+    }
+
+    @Test
+    public void test_toBytes() {
+        ExtendedAdvertisement adv = ExtendedAdvertisement.createFromRequest(mBuilder.build());
+        assertThat(adv.toBytes()).isEqualTo(getExtendedAdvertisementByteArray());
+    }
+
+    @Test
+    public void test_fromBytes() {
+        byte[] originalBytes = getExtendedAdvertisementByteArray();
+        ExtendedAdvertisement adv =
+                ExtendedAdvertisement.fromBytes(originalBytes, mPublicCredential);
+
+        assertThat(adv.getActions())
+                .containsExactly(PRESENCE_ACTION_1, PRESENCE_ACTION_2);
+        assertThat(adv.getIdentity()).isEqualTo(IDENTITY);
+        assertThat(adv.getIdentityType()).isEqualTo(IDENTITY_TYPE);
+        assertThat(adv.getLength()).isEqualTo(66);
+        assertThat(adv.getVersion()).isEqualTo(
+                BroadcastRequest.PRESENCE_VERSION_V1);
+        assertThat(adv.getSalt()).isEqualTo(SALT);
+        assertThat(adv.getDataElements())
+                .containsExactly(MODE_ID_ADDRESS_ELEMENT, BLE_ADDRESS_ELEMENT);
+    }
+
+    @Test
+    public void test_toString() {
+        ExtendedAdvertisement adv = ExtendedAdvertisement.createFromRequest(mBuilder.build());
+        assertThat(adv.toString()).isEqualTo("ExtendedAdvertisement:"
+                + "<VERSION: 1, length: 66, dataElementCount: 2, identityType: 1, "
+                + "identity: [1, 2, 3, 4, 1, 2, 3, 4, 1, 2, 3, 4, 1, 2, 3, 4], salt: [2, 3],"
+                + " actions: [1, 2]>");
+    }
+
+    @Test
+    public void test_getDataElements_accordingToType() {
+        ExtendedAdvertisement adv = ExtendedAdvertisement.createFromRequest(mBuilder.build());
+        List<DataElement> dataElements = new ArrayList<>();
+
+        dataElements.add(BLE_ADDRESS_ELEMENT);
+        assertThat(adv.getDataElements(DATA_TYPE_BLE_ADDRESS)).isEqualTo(dataElements);
+        assertThat(adv.getDataElements(DATA_TYPE_PUBLIC_IDENTITY)).isEmpty();
+    }
+
+    private static byte[] getExtendedAdvertisementByteArray() {
+        ByteBuffer buffer = ByteBuffer.allocate(66);
+        buffer.put((byte) 0b00100000); // Header V1
+        buffer.put((byte) 0b00100000); // Salt header: length 2, type 0
+        // Salt data
+        buffer.put(SALT);
+        // Identity header: length 16, type 1 (private identity)
+        buffer.put(new byte[]{(byte) 0b10010000, (byte) 0b00000001});
+        // Identity data
+        buffer.put(CryptorImpIdentityV1.getInstance().encrypt(IDENTITY, SALT, AUTHENTICITY_KEY));
+
+        ByteBuffer deBuffer = ByteBuffer.allocate(28);
+        // Action1 header: length 1, type 6
+        deBuffer.put(new byte[]{(byte) 0b00010110});
+        // Action1 data
+        deBuffer.put((byte) PRESENCE_ACTION_1);
+        // Action2 header: length 1, type 6
+        deBuffer.put(new byte[]{(byte) 0b00010110});
+        // Action2 data
+        deBuffer.put((byte) PRESENCE_ACTION_2);
+        // Ble address header: length 7, type 102
+        deBuffer.put(new byte[]{(byte) 0b10000111, (byte) 0b01100101});
+        // Ble address data
+        deBuffer.put(BLE_ADDRESS);
+        // model id header: length 13, type 7
+        deBuffer.put(new byte[]{(byte) 0b10001101, (byte) 0b00000111});
+        // model id data
+        deBuffer.put(MODE_ID_DATA);
+
+        byte[] data = deBuffer.array();
+        CryptorImpV1 cryptor = CryptorImpV1.getInstance();
+        buffer.put(cryptor.encrypt(data, SALT, AUTHENTICITY_KEY));
+        buffer.put(cryptor.sign(data, AUTHENTICITY_KEY));
+
+        return buffer.array();
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/presence/ExtendedAdvertisementUtilsTest.java b/nearby/tests/unit/src/com/android/server/nearby/presence/ExtendedAdvertisementUtilsTest.java
new file mode 100644
index 0000000..c4fccf7
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/presence/ExtendedAdvertisementUtilsTest.java
@@ -0,0 +1,103 @@
+/*
+ * 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.nearby.presence;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.nearby.BroadcastRequest;
+import android.nearby.DataElement;
+
+import org.junit.Test;
+
+import java.util.Arrays;
+
+/**
+ * Unit test for {@link ExtendedAdvertisementUtils}.
+ */
+public class ExtendedAdvertisementUtilsTest {
+    private static final byte[] ADVERTISEMENT1 = new byte[]{0b00100000, 12, 34, 78, 10};
+    private static final byte[] ADVERTISEMENT2 = new byte[]{0b00100000, 0b00100011, 34, 78,
+            (byte) 0b10010000, (byte) 0b00000100,
+            1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16};
+
+    private static final int DATA_TYPE_SALT = 3;
+    private static final int DATA_TYPE_PRIVATE_IDENTITY = 4;
+
+    @Test
+    public void test_constructHeader() {
+        assertThat(ExtendedAdvertisementUtils.constructHeader(1)).isEqualTo(0b100000);
+        assertThat(ExtendedAdvertisementUtils.constructHeader(0)).isEqualTo(0);
+        assertThat(ExtendedAdvertisementUtils.constructHeader(6)).isEqualTo((byte) 0b11000000);
+    }
+
+    @Test
+    public void test_getVersion() {
+        assertThat(ExtendedAdvertisementUtils.getVersion(ADVERTISEMENT1)).isEqualTo(1);
+        byte[] adv = new byte[]{(byte) 0b10111100, 9, 19, 90, 23};
+        assertThat(ExtendedAdvertisementUtils.getVersion(adv)).isEqualTo(5);
+        byte[] adv2 = new byte[]{(byte) 0b10011111, 9, 19, 90, 23};
+        assertThat(ExtendedAdvertisementUtils.getVersion(adv2)).isEqualTo(4);
+    }
+
+    @Test
+    public void test_getDataElementHeader_salt() {
+        byte[] saltHeaderArray = ExtendedAdvertisementUtils.getDataElementHeader(ADVERTISEMENT2, 1);
+        DataElementHeader header = DataElementHeader.fromBytes(
+                BroadcastRequest.PRESENCE_VERSION_V1, saltHeaderArray);
+        assertThat(header.getDataType()).isEqualTo(DATA_TYPE_SALT);
+        assertThat(header.getDataLength()).isEqualTo(ExtendedAdvertisement.SALT_DATA_LENGTH);
+    }
+
+    @Test
+    public void test_getDataElementHeader_identity() {
+        byte[] identityHeaderArray =
+                ExtendedAdvertisementUtils.getDataElementHeader(ADVERTISEMENT2, 4);
+        DataElementHeader header = DataElementHeader.fromBytes(BroadcastRequest.PRESENCE_VERSION_V1,
+                identityHeaderArray);
+        assertThat(header.getDataType()).isEqualTo(DATA_TYPE_PRIVATE_IDENTITY);
+        assertThat(header.getDataLength()).isEqualTo(ExtendedAdvertisement.IDENTITY_DATA_LENGTH);
+    }
+
+    @Test
+    public void test_constructDataElement_salt() {
+        DataElement salt = new DataElement(DATA_TYPE_SALT, new byte[]{13, 14});
+        byte[] saltArray = ExtendedAdvertisementUtils.convertDataElementToBytes(salt);
+        // Data length and salt header length.
+        assertThat(saltArray.length).isEqualTo(ExtendedAdvertisement.SALT_DATA_LENGTH + 1);
+        // Header
+        assertThat(saltArray[0]).isEqualTo((byte) 0b00100011);
+        // Data
+        assertThat(saltArray[1]).isEqualTo((byte) 13);
+        assertThat(saltArray[2]).isEqualTo((byte) 14);
+    }
+
+    @Test
+    public void test_constructDataElement_privateIdentity() {
+        byte[] identityData = new byte[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16};
+        DataElement identity = new DataElement(DATA_TYPE_PRIVATE_IDENTITY, identityData);
+        byte[] identityArray = ExtendedAdvertisementUtils.convertDataElementToBytes(identity);
+        // Data length and identity header length.
+        assertThat(identityArray.length).isEqualTo(ExtendedAdvertisement.IDENTITY_DATA_LENGTH + 2);
+        // 1st header byte
+        assertThat(identityArray[0]).isEqualTo((byte) 0b10010000);
+        // 2st header byte
+        assertThat(identityArray[1]).isEqualTo((byte) 0b00000100);
+        // Data
+        assertThat(Arrays.copyOfRange(identityArray, 2, identityArray.length))
+                .isEqualTo(identityData);
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/presence/FastAdvertisementTest.java b/nearby/tests/unit/src/com/android/server/nearby/presence/FastAdvertisementTest.java
index 5e0ccbe..8e3e068 100644
--- a/nearby/tests/unit/src/com/android/server/nearby/presence/FastAdvertisementTest.java
+++ b/nearby/tests/unit/src/com/android/server/nearby/presence/FastAdvertisementTest.java
@@ -75,6 +75,15 @@
         assertThat(originalAdvertisement.getVersion()).isEqualTo(
                 BroadcastRequest.PRESENCE_VERSION_V0);
         assertThat(originalAdvertisement.getSalt()).isEqualTo(SALT);
+        assertThat(originalAdvertisement.getTxPower()).isEqualTo(TX_POWER);
+        assertThat(originalAdvertisement.toString())
+                .isEqualTo("FastAdvertisement:<VERSION: 0, length: 19,"
+                        + " ltvFieldCount: 4,"
+                        + " identityType: 1,"
+                        + " identity: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],"
+                        + " salt: [2, 3],"
+                        + " actions: [123],"
+                        + " txPower: 4");
     }
 
     @Test
diff --git a/nearby/tests/unit/src/com/android/server/nearby/presence/PresenceDiscoveryResultTest.java b/nearby/tests/unit/src/com/android/server/nearby/presence/PresenceDiscoveryResultTest.java
index 39cab94..856c1a8 100644
--- a/nearby/tests/unit/src/com/android/server/nearby/presence/PresenceDiscoveryResultTest.java
+++ b/nearby/tests/unit/src/com/android/server/nearby/presence/PresenceDiscoveryResultTest.java
@@ -18,6 +18,8 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
+import android.nearby.DataElement;
+import android.nearby.NearbyDeviceParcelable;
 import android.nearby.PresenceCredential;
 import android.nearby.PresenceDevice;
 import android.nearby.PresenceScanFilter;
@@ -28,12 +30,15 @@
 import org.junit.Before;
 import org.junit.Test;
 
-import java.util.Arrays;
+import java.util.ArrayList;
+import java.util.List;
 
 /**
  * Unit tests for {@link PresenceDiscoveryResult}.
  */
 public class PresenceDiscoveryResultTest {
+    private static final int DATA_TYPE_ACCOUNT_KEY = 9;
+    private static final int DATA_TYPE_INTENT = 6;
     private static final int PRESENCE_ACTION = 123;
     private static final int TX_POWER = -1;
     private static final int RSSI = -41;
@@ -43,6 +48,8 @@
     private static final byte[] PUBLIC_KEY = new byte[]{1, 1, 2, 2};
     private static final byte[] ENCRYPTED_METADATA = new byte[]{1, 2, 3, 4, 5};
     private static final byte[] METADATA_ENCRYPTION_KEY_TAG = new byte[]{1, 1, 3, 4, 5};
+    private static final byte[] META_DATA_ENCRYPTION_KEY =
+            new byte[] {-39, -55, 115, 78, -57, 40, 115, 0, -112, 86, -86, 7, -42, 68, 11, 12};
 
     private PresenceDiscoveryResult.Builder mBuilder;
     private PublicCredential mCredential;
@@ -59,18 +66,68 @@
                 .setSalt(SALT)
                 .setTxPower(TX_POWER)
                 .setRssi(RSSI)
+                .setEncryptedIdentityTag(METADATA_ENCRYPTION_KEY_TAG)
                 .addPresenceAction(PRESENCE_ACTION);
     }
 
     @Test
     @SdkSuppress(minSdkVersion = 32, codeName = "T")
-    public void testToDevice() {
-        PresenceDiscoveryResult discoveryResult = mBuilder.build();
-        PresenceDevice presenceDevice = discoveryResult.toPresenceDevice();
+    public void testFromDevice() {
+        NearbyDeviceParcelable.Builder builder = new NearbyDeviceParcelable.Builder();
+        builder.setTxPower(TX_POWER)
+                .setRssi(RSSI)
+                .setEncryptionKeyTag(METADATA_ENCRYPTION_KEY_TAG)
+                .setSalt(SALT)
+                .setPublicCredential(mCredential);
 
-        assertThat(presenceDevice.getRssi()).isEqualTo(RSSI);
-        assertThat(Arrays.equals(presenceDevice.getSalt(), SALT)).isTrue();
-        assertThat(Arrays.equals(presenceDevice.getSecretId(), SECRET_ID)).isTrue();
+        PresenceDiscoveryResult discoveryResult =
+                PresenceDiscoveryResult.fromDevice(builder.build());
+        PresenceScanFilter scanFilter = new PresenceScanFilter.Builder()
+                .setMaxPathLoss(80)
+                .addCredential(mCredential)
+                .build();
+
+        assertThat(discoveryResult.matches(scanFilter)).isTrue();
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testFromDevice_presenceDeviceAvailable() {
+        NearbyDeviceParcelable.Builder builder = new NearbyDeviceParcelable.Builder();
+        PresenceDevice presenceDevice =
+                new PresenceDevice.Builder("123", SALT, SECRET_ID, META_DATA_ENCRYPTION_KEY)
+                        .addExtendedProperty(new DataElement(
+                                DATA_TYPE_INTENT, new byte[]{(byte) PRESENCE_ACTION}))
+                        .build();
+        builder.setTxPower(TX_POWER)
+                .setRssi(RSSI)
+                .setEncryptionKeyTag(METADATA_ENCRYPTION_KEY_TAG)
+                .setPresenceDevice(presenceDevice)
+                .setPublicCredential(mCredential);
+
+        PresenceDiscoveryResult discoveryResult =
+                PresenceDiscoveryResult.fromDevice(builder.build());
+        PresenceScanFilter scanFilter = new PresenceScanFilter.Builder()
+                .setMaxPathLoss(80)
+                .addPresenceAction(PRESENCE_ACTION)
+                .addCredential(mCredential)
+                .build();
+
+        assertThat(discoveryResult.matches(scanFilter)).isTrue();
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testAccountMatches() {
+        DataElement accountKey = new DataElement(DATA_TYPE_ACCOUNT_KEY, new byte[]{1, 2, 3, 4});
+        mBuilder.addExtendedProperties(List.of(accountKey));
+        PresenceDiscoveryResult discoveryResult = mBuilder.build();
+
+        List<DataElement> extendedProperties = new ArrayList<>();
+        extendedProperties.add(new DataElement(DATA_TYPE_ACCOUNT_KEY, new byte[]{1, 2, 3, 4}));
+        extendedProperties.add(new DataElement(DATA_TYPE_INTENT,
+                new byte[]{(byte) PRESENCE_ACTION}));
+        assertThat(discoveryResult.accountKeyMatches(extendedProperties)).isTrue();
     }
 
     @Test
@@ -86,4 +143,24 @@
         assertThat(discoveryResult.matches(scanFilter)).isTrue();
     }
 
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_notMatches() {
+        PresenceDiscoveryResult.Builder builder = new PresenceDiscoveryResult.Builder()
+                .setPublicCredential(mCredential)
+                .setSalt(SALT)
+                .setTxPower(TX_POWER)
+                .setRssi(RSSI)
+                .setEncryptedIdentityTag(new byte[]{5, 4, 3, 2, 1})
+                .addPresenceAction(PRESENCE_ACTION);
+
+        PresenceScanFilter scanFilter = new PresenceScanFilter.Builder()
+                .setMaxPathLoss(80)
+                .addPresenceAction(PRESENCE_ACTION)
+                .addCredential(mCredential)
+                .build();
+
+        PresenceDiscoveryResult discoveryResult = builder.build();
+        assertThat(discoveryResult.matches(scanFilter)).isFalse();
+    }
 }
diff --git a/nearby/tests/unit/src/com/android/server/nearby/presence/PresenceManagerTest.java b/nearby/tests/unit/src/com/android/server/nearby/presence/PresenceManagerTest.java
new file mode 100644
index 0000000..ca4f077
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/presence/PresenceManagerTest.java
@@ -0,0 +1,80 @@
+/*
+ * 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.nearby.presence;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.content.Context;
+import android.nearby.DataElement;
+import android.nearby.PresenceDevice;
+
+import androidx.test.filters.SdkSuppress;
+import androidx.test.platform.app.InstrumentationRegistry;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+public class PresenceManagerTest {
+    private static final byte[] IDENTITY =
+            new byte[] {1, 2, 3, 4, 1, 2, 3, 4, 1, 2, 3, 4, 1, 2, 3, 4};
+    private static final byte[] SALT = {2, 3};
+    private static final byte[] SECRET_ID =
+            new byte[] {-97, 10, 107, -86, 25, 65, -54, -95, -72, 59, 54, 93, 9, 3, -24, -88};
+
+    @Mock private Context mContext;
+    private PresenceManager mPresenceManager;
+
+    @Before
+    public void setup() {
+        MockitoAnnotations.initMocks(this);
+
+        mPresenceManager = new PresenceManager(mContext);
+        when(mContext.getContentResolver())
+                .thenReturn(InstrumentationRegistry.getInstrumentation()
+                        .getContext().getContentResolver());
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testInit() {
+        mPresenceManager.initiate();
+
+        verify(mContext, times(1)).registerReceiver(any(), any());
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testDeviceStatusUpdated() {
+        DataElement dataElement1 = new DataElement(1, new byte[] {1, 2});
+        DataElement dataElement2 = new DataElement(2, new byte[] {-1, -2, 3, 4, 5, 6, 7, 8, 9});
+
+        PresenceDevice presenceDevice =
+                new PresenceDevice.Builder(/* deviceId= */ "deviceId", SALT, SECRET_ID, IDENTITY)
+                        .addExtendedProperty(dataElement1)
+                        .addExtendedProperty(dataElement2)
+                        .build();
+
+        mPresenceManager.mScanCallback.onDiscovered(presenceDevice);
+        mPresenceManager.mScanCallback.onUpdated(presenceDevice);
+        mPresenceManager.mScanCallback.onLost(presenceDevice);
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/provider/BleBroadcastProviderTest.java b/nearby/tests/unit/src/com/android/server/nearby/provider/BleBroadcastProviderTest.java
index d06a785..05b556b 100644
--- a/nearby/tests/unit/src/com/android/server/nearby/provider/BleBroadcastProviderTest.java
+++ b/nearby/tests/unit/src/com/android/server/nearby/provider/BleBroadcastProviderTest.java
@@ -16,6 +16,7 @@
 
 package com.android.server.nearby.provider;
 
+import static org.mockito.Mockito.atLeastOnce;
 import static org.mockito.Mockito.eq;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
@@ -24,12 +25,14 @@
 import android.bluetooth.BluetoothAdapter;
 import android.bluetooth.BluetoothManager;
 import android.bluetooth.le.AdvertiseSettings;
+import android.bluetooth.le.AdvertisingSetCallback;
 import android.content.Context;
+import android.hardware.location.ContextHubManager;
 import android.nearby.BroadcastCallback;
+import android.nearby.BroadcastRequest;
 
 import androidx.test.core.app.ApplicationProvider;
 
-import com.android.server.nearby.injector.ContextHubManagerAdapter;
 import com.android.server.nearby.injector.Injector;
 
 import com.google.common.util.concurrent.MoreExecutors;
@@ -59,9 +62,10 @@
     }
 
     @Test
-    public void testOnStatus_success() {
+    public void testOnStatus_success_fastAdv() {
         byte[] advertiseBytes = new byte[]{1, 2, 3, 4};
-        mBleBroadcastProvider.start(advertiseBytes, mBroadcastListener);
+        mBleBroadcastProvider.start(BroadcastRequest.PRESENCE_VERSION_V0,
+                advertiseBytes, mBroadcastListener);
 
         AdvertiseSettings settings = new AdvertiseSettings.Builder().build();
         mBleBroadcastProvider.onStartSuccess(settings);
@@ -69,15 +73,47 @@
     }
 
     @Test
-    public void testOnStatus_failure() {
+    public void testOnStatus_success_extendedAdv() {
         byte[] advertiseBytes = new byte[]{1, 2, 3, 4};
-        mBleBroadcastProvider.start(advertiseBytes, mBroadcastListener);
+        mBleBroadcastProvider.start(BroadcastRequest.PRESENCE_VERSION_V1,
+                advertiseBytes, mBroadcastListener);
+
+        // advertising set can not be mocked, so we will allow nulls
+        mBleBroadcastProvider.mAdvertisingSetCallback.onAdvertisingSetStarted(null, -30,
+                AdvertisingSetCallback.ADVERTISE_SUCCESS);
+        verify(mBroadcastListener).onStatusChanged(eq(BroadcastCallback.STATUS_OK));
+    }
+
+    @Test
+    public void testOnStatus_failure_fastAdv() {
+        byte[] advertiseBytes = new byte[]{1, 2, 3, 4};
+        mBleBroadcastProvider.start(BroadcastRequest.PRESENCE_VERSION_V0,
+                advertiseBytes, mBroadcastListener);
 
         mBleBroadcastProvider.onStartFailure(BroadcastCallback.STATUS_FAILURE);
         verify(mBroadcastListener, times(1))
                 .onStatusChanged(eq(BroadcastCallback.STATUS_FAILURE));
     }
 
+    @Test
+    public void testOnStatus_failure_extendedAdv() {
+        byte[] advertiseBytes = new byte[]{1, 2, 3, 4};
+        mBleBroadcastProvider.start(BroadcastRequest.PRESENCE_VERSION_V1,
+                advertiseBytes, mBroadcastListener);
+
+        // advertising set can not be mocked, so we will allow nulls
+        mBleBroadcastProvider.mAdvertisingSetCallback.onAdvertisingSetStarted(null, -30,
+                AdvertisingSetCallback.ADVERTISE_FAILED_INTERNAL_ERROR);
+        // Can be additional failure if the test device does not support LE Extended Advertising.
+        verify(mBroadcastListener, atLeastOnce())
+                .onStatusChanged(eq(BroadcastCallback.STATUS_FAILURE));
+    }
+
+    @Test
+    public void testStop() {
+        mBleBroadcastProvider.stop();
+    }
+
     private static class TestInjector implements Injector {
 
         @Override
@@ -88,7 +124,7 @@
         }
 
         @Override
-        public ContextHubManagerAdapter getContextHubManagerAdapter() {
+        public ContextHubManager getContextHubManager() {
             return null;
         }
 
diff --git a/nearby/tests/unit/src/com/android/server/nearby/provider/BleDiscoveryProviderTest.java b/nearby/tests/unit/src/com/android/server/nearby/provider/BleDiscoveryProviderTest.java
index 902cc33..2d8bd63 100644
--- a/nearby/tests/unit/src/com/android/server/nearby/provider/BleDiscoveryProviderTest.java
+++ b/nearby/tests/unit/src/com/android/server/nearby/provider/BleDiscoveryProviderTest.java
@@ -17,6 +17,9 @@
 package com.android.server.nearby.provider;
 
 import static android.bluetooth.le.ScanSettings.CALLBACK_TYPE_ALL_MATCHES;
+import static android.nearby.ScanCallback.ERROR_UNKNOWN;
+
+import static com.google.common.truth.Truth.assertThat;
 
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.times;
@@ -30,10 +33,13 @@
 import android.bluetooth.le.ScanRecord;
 import android.bluetooth.le.ScanResult;
 import android.content.Context;
+import android.hardware.location.ContextHubManager;
+import android.nearby.PresenceScanFilter;
+import android.nearby.PublicCredential;
+import android.nearby.ScanFilter;
 
 import androidx.test.platform.app.InstrumentationRegistry;
 
-import com.android.server.nearby.injector.ContextHubManagerAdapter;
 import com.android.server.nearby.injector.Injector;
 
 import org.junit.Before;
@@ -42,6 +48,8 @@
 
 import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.List;
 
 public final class BleDiscoveryProviderTest {
 
@@ -49,6 +57,8 @@
     private BleDiscoveryProvider mBleDiscoveryProvider;
     @Mock
     private AbstractDiscoveryProvider.Listener mListener;
+//    @Mock
+//    private BluetoothAdapter mBluetoothAdapter;
 
     @Before
     public void setup() {
@@ -61,7 +71,7 @@
     }
 
     @Test
-    public void test_callback() throws InterruptedException {
+    public void test_callback_found() throws InterruptedException {
         mBleDiscoveryProvider.getController().setListener(mListener);
         mBleDiscoveryProvider.onStart();
         mBleDiscoveryProvider.getScanCallback()
@@ -73,11 +83,39 @@
     }
 
     @Test
+    public void test_callback_failed() throws InterruptedException {
+        mBleDiscoveryProvider.getController().setListener(mListener);
+        mBleDiscoveryProvider.onStart();
+        mBleDiscoveryProvider.getScanCallback().onScanFailed(1);
+
+
+        // Wait for callback to be invoked
+        Thread.sleep(500);
+        verify(mListener, times(1)).onError(ERROR_UNKNOWN);
+    }
+
+    @Test
     public void test_stopScan() {
         mBleDiscoveryProvider.onStart();
         mBleDiscoveryProvider.onStop();
     }
 
+    @Test
+    public void test_stopScan_filersReset() {
+        List<ScanFilter> filterList = new ArrayList<>();
+        filterList.add(getSanFilter());
+
+        mBleDiscoveryProvider.getController().setProviderScanFilters(filterList);
+        mBleDiscoveryProvider.onStart();
+        mBleDiscoveryProvider.onStop();
+        assertThat(mBleDiscoveryProvider.getFiltersLocked()).isNull();
+    }
+
+    @Test
+    public void testInvalidateScanMode() {
+        mBleDiscoveryProvider.invalidateScanMode();
+    }
+
     private class TestInjector implements Injector {
         @Override
         public BluetoothAdapter getBluetoothAdapter() {
@@ -85,7 +123,7 @@
         }
 
         @Override
-        public ContextHubManagerAdapter getContextHubManagerAdapter() {
+        public ContextHubManager getContextHubManager() {
             return null;
         }
 
@@ -125,4 +163,22 @@
             return null;
         }
     }
+
+    private static PresenceScanFilter getSanFilter() {
+        return new PresenceScanFilter.Builder()
+                .setMaxPathLoss(70)
+                .addCredential(getPublicCredential())
+                .addPresenceAction(124)
+                .build();
+    }
+
+    private static PublicCredential getPublicCredential() {
+        return new PublicCredential.Builder(
+                new byte[]{1, 2},
+                new byte[]{1, 2},
+                new byte[]{1, 2},
+                new byte[]{1, 2},
+                new byte[]{1, 2})
+                .build();
+    }
 }
diff --git a/nearby/tests/unit/src/com/android/server/nearby/provider/ChreCommunicationTest.java b/nearby/tests/unit/src/com/android/server/nearby/provider/ChreCommunicationTest.java
index 1b29b52..ce479c8 100644
--- a/nearby/tests/unit/src/com/android/server/nearby/provider/ChreCommunicationTest.java
+++ b/nearby/tests/unit/src/com/android/server/nearby/provider/ChreCommunicationTest.java
@@ -16,18 +16,33 @@
 
 package com.android.server.nearby.provider;
 
+import static android.Manifest.permission.READ_DEVICE_CONFIG;
+import static android.Manifest.permission.WRITE_DEVICE_CONFIG;
+
+import static com.android.server.nearby.NearbyConfiguration.NEARBY_MAINLINE_NANO_APP_MIN_VERSION;
+import static com.android.server.nearby.provider.ChreCommunication.INVALID_NANO_APP_VERSION;
+
+import static com.google.common.truth.Truth.assertThat;
+
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
+import android.content.Context;
 import android.hardware.location.ContextHubClient;
 import android.hardware.location.ContextHubInfo;
+import android.hardware.location.ContextHubManager;
 import android.hardware.location.ContextHubTransaction;
 import android.hardware.location.NanoAppMessage;
 import android.hardware.location.NanoAppState;
+import android.os.Build;
+import android.provider.DeviceConfig;
 
-import com.android.server.nearby.injector.ContextHubManagerAdapter;
+import androidx.test.filters.SdkSuppress;
+import androidx.test.platform.app.InstrumentationRegistry;
+
+import com.android.server.nearby.NearbyConfiguration;
 import com.android.server.nearby.injector.Injector;
 
 import org.junit.Before;
@@ -42,8 +57,12 @@
 import java.util.concurrent.Executor;
 
 public class ChreCommunicationTest {
+    private static final String NAMESPACE = NearbyConfiguration.getNamespace();
+    private static final int APP_VERSION = 1;
+
     @Mock Injector mInjector;
-    @Mock ContextHubManagerAdapter mManager;
+    @Mock Context mContext;
+    @Mock ContextHubManager mManager;
     @Mock ContextHubTransaction<List<NanoAppState>> mTransaction;
     @Mock ContextHubTransaction.Response<List<NanoAppState>> mTransactionResponse;
     @Mock ContextHubClient mClient;
@@ -56,38 +75,78 @@
 
     @Before
     public void setUp() {
+        InstrumentationRegistry.getInstrumentation().getUiAutomation()
+                .adoptShellPermissionIdentity(WRITE_DEVICE_CONFIG, READ_DEVICE_CONFIG);
+        DeviceConfig.setProperty(
+                NAMESPACE, NEARBY_MAINLINE_NANO_APP_MIN_VERSION, "1", false);
+
         MockitoAnnotations.initMocks(this);
-        when(mInjector.getContextHubManagerAdapter()).thenReturn(mManager);
+        when(mInjector.getContextHubManager()).thenReturn(mManager);
         when(mManager.getContextHubs()).thenReturn(Collections.singletonList(new ContextHubInfo()));
         when(mManager.queryNanoApps(any())).thenReturn(mTransaction);
-        when(mManager.createClient(any(), any(), any())).thenReturn(mClient);
+        when(mManager.createClient(any(), any(), any(), any())).thenReturn(mClient);
         when(mTransactionResponse.getResult()).thenReturn(ContextHubTransaction.RESULT_SUCCESS);
         when(mTransactionResponse.getContents())
                 .thenReturn(
                         Collections.singletonList(
-                                new NanoAppState(ChreDiscoveryProvider.NANOAPP_ID, 1, true)));
+                                new NanoAppState(
+                                        ChreDiscoveryProvider.NANOAPP_ID,
+                                        APP_VERSION,
+                                        true)));
 
-        mChreCommunication = new ChreCommunication(mInjector, new InlineExecutor());
+        mChreCommunication = new ChreCommunication(mInjector, mContext, new InlineExecutor());
+    }
+
+    @Test
+    public void testStart() {
         mChreCommunication.start(
                 mChreCallback, Collections.singleton(ChreDiscoveryProvider.NANOAPP_ID));
 
         verify(mTransaction).setOnCompleteListener(mOnQueryCompleteListenerCaptor.capture(), any());
         mOnQueryCompleteListenerCaptor.getValue().onComplete(mTransaction, mTransactionResponse);
-    }
-
-    @Test
-    public void testStart() {
         verify(mChreCallback).started(true);
     }
 
     @Test
     public void testStop() {
+        mChreCommunication.start(
+                mChreCallback, Collections.singleton(ChreDiscoveryProvider.NANOAPP_ID));
+
+        verify(mTransaction).setOnCompleteListener(mOnQueryCompleteListenerCaptor.capture(), any());
+        mOnQueryCompleteListenerCaptor.getValue().onComplete(mTransaction, mTransactionResponse);
         mChreCommunication.stop();
         verify(mClient).close();
     }
 
     @Test
+    public void testNotReachMinVersion() {
+        DeviceConfig.setProperty(NAMESPACE, NEARBY_MAINLINE_NANO_APP_MIN_VERSION, "3", false);
+        mChreCommunication.start(
+                mChreCallback, Collections.singleton(ChreDiscoveryProvider.NANOAPP_ID));
+        verify(mTransaction).setOnCompleteListener(mOnQueryCompleteListenerCaptor.capture(), any());
+        mOnQueryCompleteListenerCaptor.getValue().onComplete(mTransaction, mTransactionResponse);
+        verify(mChreCallback).started(false);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+    public void test_getNanoVersion() {
+        assertThat(mChreCommunication.queryNanoAppVersion()).isEqualTo(INVALID_NANO_APP_VERSION);
+
+        mChreCommunication.start(
+                mChreCallback, Collections.singleton(ChreDiscoveryProvider.NANOAPP_ID));
+        verify(mTransaction).setOnCompleteListener(mOnQueryCompleteListenerCaptor.capture(), any());
+        mOnQueryCompleteListenerCaptor.getValue().onComplete(mTransaction, mTransactionResponse);
+
+        assertThat(mChreCommunication.queryNanoAppVersion()).isEqualTo(APP_VERSION);
+    }
+
+    @Test
     public void testSendMessageToNanApp() {
+        mChreCommunication.start(
+                mChreCallback, Collections.singleton(ChreDiscoveryProvider.NANOAPP_ID));
+        verify(mTransaction).setOnCompleteListener(mOnQueryCompleteListenerCaptor.capture(), any());
+        mOnQueryCompleteListenerCaptor.getValue().onComplete(mTransaction, mTransactionResponse);
         NanoAppMessage message =
                 NanoAppMessage.createMessageToNanoApp(
                         ChreDiscoveryProvider.NANOAPP_ID,
@@ -99,6 +158,8 @@
 
     @Test
     public void testOnMessageFromNanoApp() {
+        mChreCommunication.start(
+                mChreCallback, Collections.singleton(ChreDiscoveryProvider.NANOAPP_ID));
         NanoAppMessage message =
                 NanoAppMessage.createMessageToNanoApp(
                         ChreDiscoveryProvider.NANOAPP_ID,
@@ -109,13 +170,60 @@
     }
 
     @Test
+    public void testContextHubTransactionResultToString() {
+        assertThat(
+                mChreCommunication.contextHubTransactionResultToString(
+                        ContextHubTransaction.RESULT_SUCCESS))
+                .isEqualTo("RESULT_SUCCESS");
+        assertThat(
+                mChreCommunication.contextHubTransactionResultToString(
+                        ContextHubTransaction.RESULT_FAILED_UNKNOWN))
+                .isEqualTo("RESULT_FAILED_UNKNOWN");
+        assertThat(
+                mChreCommunication.contextHubTransactionResultToString(
+                        ContextHubTransaction.RESULT_FAILED_BAD_PARAMS))
+                .isEqualTo("RESULT_FAILED_BAD_PARAMS");
+        assertThat(
+                mChreCommunication.contextHubTransactionResultToString(
+                        ContextHubTransaction.RESULT_FAILED_UNINITIALIZED))
+                .isEqualTo("RESULT_FAILED_UNINITIALIZED");
+        assertThat(
+                mChreCommunication.contextHubTransactionResultToString(
+                        ContextHubTransaction.RESULT_FAILED_BUSY))
+                .isEqualTo("RESULT_FAILED_BUSY");
+        assertThat(
+                mChreCommunication.contextHubTransactionResultToString(
+                        ContextHubTransaction.RESULT_FAILED_AT_HUB))
+                .isEqualTo("RESULT_FAILED_AT_HUB");
+        assertThat(
+                mChreCommunication.contextHubTransactionResultToString(
+                        ContextHubTransaction.RESULT_FAILED_TIMEOUT))
+                .isEqualTo("RESULT_FAILED_TIMEOUT");
+        assertThat(
+                mChreCommunication.contextHubTransactionResultToString(
+                        ContextHubTransaction.RESULT_FAILED_SERVICE_INTERNAL_FAILURE))
+                .isEqualTo("RESULT_FAILED_SERVICE_INTERNAL_FAILURE");
+        assertThat(
+                mChreCommunication.contextHubTransactionResultToString(
+                        ContextHubTransaction.RESULT_FAILED_HAL_UNAVAILABLE))
+                .isEqualTo("RESULT_FAILED_HAL_UNAVAILABLE");
+        assertThat(
+                mChreCommunication.contextHubTransactionResultToString(9))
+                .isEqualTo("UNKNOWN_RESULT value=9");
+    }
+
+    @Test
     public void testOnHubReset() {
+        mChreCommunication.start(
+                mChreCallback, Collections.singleton(ChreDiscoveryProvider.NANOAPP_ID));
         mChreCommunication.onHubReset(mClient);
         verify(mChreCallback).onHubReset();
     }
 
     @Test
     public void testOnNanoAppLoaded() {
+        mChreCommunication.start(
+                mChreCallback, Collections.singleton(ChreDiscoveryProvider.NANOAPP_ID));
         mChreCommunication.onNanoAppLoaded(mClient, ChreDiscoveryProvider.NANOAPP_ID);
         verify(mChreCallback).onNanoAppRestart(eq(ChreDiscoveryProvider.NANOAPP_ID));
     }
diff --git a/nearby/tests/unit/src/com/android/server/nearby/provider/ChreDiscoveryProviderTest.java b/nearby/tests/unit/src/com/android/server/nearby/provider/ChreDiscoveryProviderTest.java
index 7c0dd92..154441b 100644
--- a/nearby/tests/unit/src/com/android/server/nearby/provider/ChreDiscoveryProviderTest.java
+++ b/nearby/tests/unit/src/com/android/server/nearby/provider/ChreDiscoveryProviderTest.java
@@ -16,16 +16,34 @@
 
 package com.android.server.nearby.provider;
 
+import static android.Manifest.permission.READ_DEVICE_CONFIG;
+import static android.Manifest.permission.WRITE_DEVICE_CONFIG;
+
+import static com.android.server.nearby.NearbyConfiguration.NEARBY_SUPPORT_TEST_APP;
+
+import static com.google.common.truth.Truth.assertThat;
+
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
 
 import android.content.Context;
 import android.hardware.location.NanoAppMessage;
-import android.nearby.ScanFilter;
+import android.nearby.DataElement;
+import android.nearby.NearbyDeviceParcelable;
+import android.nearby.OffloadCapability;
+import android.nearby.aidl.IOffloadCallback;
+import android.os.Build;
+import android.os.IBinder;
+import android.os.RemoteException;
+import android.provider.DeviceConfig;
 
 import androidx.test.filters.SdkSuppress;
 import androidx.test.platform.app.InstrumentationRegistry;
 
+import com.android.server.nearby.NearbyConfiguration;
+import com.android.server.nearby.presence.PresenceDiscoveryResult;
+
 import com.google.protobuf.ByteString;
 
 import org.junit.Before;
@@ -35,22 +53,39 @@
 import org.mockito.Mock;
 import org.mockito.MockitoAnnotations;
 
-import service.proto.Blefilter;
-
 import java.util.ArrayList;
 import java.util.List;
 import java.util.concurrent.Executor;
 
+import service.proto.Blefilter;
+
 public class ChreDiscoveryProviderTest {
     @Mock AbstractDiscoveryProvider.Listener mListener;
     @Mock ChreCommunication mChreCommunication;
+    @Mock IBinder mIBinder;
 
     @Captor ArgumentCaptor<ChreCommunication.ContextHubCommsCallback> mChreCallbackCaptor;
+    @Captor ArgumentCaptor<NearbyDeviceParcelable> mNearbyDevice;
 
+    private static final String NAMESPACE = NearbyConfiguration.getNamespace();
+    private static final int DATA_TYPE_CONNECTION_STATUS_KEY = 10;
+    private static final int DATA_TYPE_BATTERY_KEY = 11;
+    private static final int DATA_TYPE_TX_POWER_KEY = 5;
+    private static final int DATA_TYPE_BLUETOOTH_ADDR_KEY = 101;
+    private static final int DATA_TYPE_FP_ACCOUNT_KEY = 9;
+    private static final int DATA_TYPE_BLE_SERVICE_DATA_KEY = 100;
+    private static final int DATA_TYPE_TEST_DE_BEGIN_KEY = 2147483520;
+    private static final int DATA_TYPE_TEST_DE_END_KEY = 2147483647;
+
+    private final Object mLock = new Object();
     private ChreDiscoveryProvider mChreDiscoveryProvider;
+    private OffloadCapability mOffloadCapability;
 
     @Before
     public void setUp() {
+        InstrumentationRegistry.getInstrumentation().getUiAutomation()
+                .adoptShellPermissionIdentity(WRITE_DEVICE_CONFIG, READ_DEVICE_CONFIG);
+
         MockitoAnnotations.initMocks(this);
         Context context = InstrumentationRegistry.getInstrumentation().getContext();
         mChreDiscoveryProvider =
@@ -59,13 +94,68 @@
 
     @Test
     @SdkSuppress(minSdkVersion = 32, codeName = "T")
-    public void testOnStart() {
-        List<ScanFilter> scanFilters = new ArrayList<>();
-        mChreDiscoveryProvider.getController().setProviderScanFilters(scanFilters);
-        mChreDiscoveryProvider.onStart();
+    public void testInit() {
+        mChreDiscoveryProvider.init();
         verify(mChreCommunication).start(mChreCallbackCaptor.capture(), any());
         mChreCallbackCaptor.getValue().started(true);
-        verify(mChreCommunication).sendMessageToNanoApp(any());
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+    public void test_queryInvalidVersion() {
+        when(mChreCommunication.queryNanoAppVersion()).thenReturn(
+                (long) ChreCommunication.INVALID_NANO_APP_VERSION);
+        IOffloadCallback callback = new IOffloadCallback() {
+            @Override
+            public void onQueryComplete(OffloadCapability capability) throws RemoteException {
+                synchronized (mLock) {
+                    mOffloadCapability = capability;
+                    mLock.notify();
+                }
+            }
+
+            @Override
+            public IBinder asBinder() {
+                return mIBinder;
+            }
+        };
+        mChreDiscoveryProvider.queryOffloadCapability(callback);
+        OffloadCapability capability =
+                new OffloadCapability
+                        .Builder()
+                        .setFastPairSupported(false)
+                        .setVersion(ChreCommunication.INVALID_NANO_APP_VERSION)
+                        .build();
+        assertThat(mOffloadCapability).isEqualTo(capability);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+    public void test_queryVersion() {
+        long version = 500L;
+        when(mChreCommunication.queryNanoAppVersion()).thenReturn(version);
+        IOffloadCallback callback = new IOffloadCallback() {
+            @Override
+            public void onQueryComplete(OffloadCapability capability) throws RemoteException {
+                synchronized (mLock) {
+                    mOffloadCapability = capability;
+                    mLock.notify();
+                }
+            }
+
+            @Override
+            public IBinder asBinder() {
+                return mIBinder;
+            }
+        };
+        mChreDiscoveryProvider.queryOffloadCapability(callback);
+        OffloadCapability capability =
+                new OffloadCapability
+                        .Builder()
+                        .setFastPairSupported(true)
+                        .setVersion(version)
+                        .build();
+        assertThat(mOffloadCapability).isEqualTo(capability);
     }
 
     @Test
@@ -93,12 +183,220 @@
                         ChreDiscoveryProvider.NANOAPP_MESSAGE_TYPE_FILTER_RESULT,
                         results.toByteArray());
         mChreDiscoveryProvider.getController().setListener(mListener);
+        mChreDiscoveryProvider.init();
         mChreDiscoveryProvider.onStart();
         verify(mChreCommunication).start(mChreCallbackCaptor.capture(), any());
         mChreCallbackCaptor.getValue().onMessageFromNanoApp(chre_message);
         verify(mListener).onNearbyDeviceDiscovered(any());
     }
 
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testOnNearbyDeviceDiscoveredWithDataElements() {
+        // Disables the setting of test app support
+        boolean isSupportedTestApp = getDeviceConfigBoolean(
+                NEARBY_SUPPORT_TEST_APP, false /* defaultValue */);
+        if (isSupportedTestApp) {
+            DeviceConfig.setProperty(NAMESPACE, NEARBY_SUPPORT_TEST_APP, "false", false);
+        }
+        assertThat(new NearbyConfiguration().isTestAppSupported()).isFalse();
+
+        final byte [] connectionStatus = new byte[] {1, 2, 3};
+        final byte [] batteryStatus = new byte[] {4, 5, 6};
+        final byte [] txPower = new byte[] {2};
+        final byte [] bluetoothAddr = new byte[] {1, 2, 3, 4, 5, 6};
+        final byte [] fastPairAccountKey = new byte[16];
+        // First byte is length of service data, padding zeros should be thrown away.
+        final byte [] bleServiceData = new byte[] {5, 1, 2, 3, 4, 5, 0, 0, 0, 0};
+        final byte [] testData = new byte[] {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
+
+        final List<DataElement> expectedExtendedProperties = new ArrayList<>();
+        expectedExtendedProperties.add(new DataElement(DATA_TYPE_CONNECTION_STATUS_KEY,
+                connectionStatus));
+        expectedExtendedProperties.add(new DataElement(DATA_TYPE_BATTERY_KEY, batteryStatus));
+        expectedExtendedProperties.add(new DataElement(DATA_TYPE_TX_POWER_KEY, txPower));
+        expectedExtendedProperties.add(
+                new DataElement(DATA_TYPE_BLUETOOTH_ADDR_KEY, bluetoothAddr));
+        expectedExtendedProperties.add(
+                new DataElement(DATA_TYPE_FP_ACCOUNT_KEY, fastPairAccountKey));
+        expectedExtendedProperties.add(
+                new DataElement(DATA_TYPE_BLE_SERVICE_DATA_KEY, new byte[] {1, 2, 3, 4, 5}));
+
+        Blefilter.PublicCredential credential =
+                Blefilter.PublicCredential.newBuilder()
+                        .setSecretId(ByteString.copyFrom(new byte[] {1}))
+                        .setAuthenticityKey(ByteString.copyFrom(new byte[2]))
+                        .setPublicKey(ByteString.copyFrom(new byte[3]))
+                        .setEncryptedMetadata(ByteString.copyFrom(new byte[4]))
+                        .setEncryptedMetadataTag(ByteString.copyFrom(new byte[5]))
+                        .build();
+        Blefilter.BleFilterResult result =
+                Blefilter.BleFilterResult.newBuilder()
+                        .setTxPower(2)
+                        .setRssi(1)
+                        .setBluetoothAddress(ByteString.copyFrom(bluetoothAddr))
+                        .setBleServiceData(ByteString.copyFrom(bleServiceData))
+                        .setPublicCredential(credential)
+                        .addDataElement(Blefilter.DataElement.newBuilder()
+                                .setKey(DATA_TYPE_CONNECTION_STATUS_KEY)
+                                .setValue(ByteString.copyFrom(connectionStatus))
+                                .setValueLength(connectionStatus.length)
+                        )
+                        .addDataElement(Blefilter.DataElement.newBuilder()
+                                .setKey(DATA_TYPE_BATTERY_KEY)
+                                .setValue(ByteString.copyFrom(batteryStatus))
+                                .setValueLength(batteryStatus.length)
+                        )
+                        .addDataElement(Blefilter.DataElement.newBuilder()
+                                .setKey(DATA_TYPE_FP_ACCOUNT_KEY)
+                                .setValue(ByteString.copyFrom(fastPairAccountKey))
+                                .setValueLength(fastPairAccountKey.length)
+                        )
+                        .addDataElement(Blefilter.DataElement.newBuilder()
+                                .setKey(DATA_TYPE_TEST_DE_BEGIN_KEY)
+                                .setValue(ByteString.copyFrom(testData))
+                                .setValueLength(testData.length)
+                        )
+                        .addDataElement(Blefilter.DataElement.newBuilder()
+                                .setKey(DATA_TYPE_TEST_DE_END_KEY)
+                                .setValue(ByteString.copyFrom(testData))
+                                .setValueLength(testData.length)
+                        )
+                        .build();
+        Blefilter.BleFilterResults results =
+                Blefilter.BleFilterResults.newBuilder().addResult(result).build();
+        NanoAppMessage chre_message =
+                NanoAppMessage.createMessageToNanoApp(
+                        ChreDiscoveryProvider.NANOAPP_ID,
+                        ChreDiscoveryProvider.NANOAPP_MESSAGE_TYPE_FILTER_RESULT,
+                        results.toByteArray());
+        mChreDiscoveryProvider.getController().setListener(mListener);
+        mChreDiscoveryProvider.init();
+        mChreDiscoveryProvider.onStart();
+        verify(mChreCommunication).start(mChreCallbackCaptor.capture(), any());
+        mChreCallbackCaptor.getValue().onMessageFromNanoApp(chre_message);
+        verify(mListener).onNearbyDeviceDiscovered(mNearbyDevice.capture());
+
+        List<DataElement> extendedProperties = PresenceDiscoveryResult
+                .fromDevice(mNearbyDevice.getValue()).getExtendedProperties();
+        assertThat(extendedProperties).containsExactlyElementsIn(expectedExtendedProperties);
+        // Reverts the setting of test app support
+        if (isSupportedTestApp) {
+            DeviceConfig.setProperty(NAMESPACE, NEARBY_SUPPORT_TEST_APP, "true", false);
+            assertThat(new NearbyConfiguration().isTestAppSupported()).isTrue();
+        }
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testOnNearbyDeviceDiscoveredWithTestDataElements() {
+        // Enables the setting of test app support
+        boolean isSupportedTestApp = getDeviceConfigBoolean(
+                NEARBY_SUPPORT_TEST_APP, false /* defaultValue */);
+        if (!isSupportedTestApp) {
+            DeviceConfig.setProperty(NAMESPACE, NEARBY_SUPPORT_TEST_APP, "true", false);
+        }
+        assertThat(new NearbyConfiguration().isTestAppSupported()).isTrue();
+
+        final byte [] connectionStatus = new byte[] {1, 2, 3};
+        final byte [] batteryStatus = new byte[] {4, 5, 6};
+        final byte [] txPower = new byte[] {2};
+        final byte [] bluetoothAddr = new byte[] {1, 2, 3, 4, 5, 6};
+        final byte [] fastPairAccountKey = new byte[16];
+        // First byte is length of service data, padding zeros should be thrown away.
+        final byte [] bleServiceData = new byte[] {5, 1, 2, 3, 4, 5, 0, 0, 0, 0};
+        final byte [] testData = new byte[] {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
+
+        final List<DataElement> expectedExtendedProperties = new ArrayList<>();
+        expectedExtendedProperties.add(new DataElement(DATA_TYPE_CONNECTION_STATUS_KEY,
+                connectionStatus));
+        expectedExtendedProperties.add(new DataElement(DATA_TYPE_BATTERY_KEY, batteryStatus));
+        expectedExtendedProperties.add(new DataElement(DATA_TYPE_TX_POWER_KEY, txPower));
+        expectedExtendedProperties.add(
+                new DataElement(DATA_TYPE_BLUETOOTH_ADDR_KEY, bluetoothAddr));
+        expectedExtendedProperties.add(
+                new DataElement(DATA_TYPE_FP_ACCOUNT_KEY, fastPairAccountKey));
+        expectedExtendedProperties.add(
+                new DataElement(DATA_TYPE_BLE_SERVICE_DATA_KEY, new byte[] {1, 2, 3, 4, 5}));
+        expectedExtendedProperties.add(
+                new DataElement(DATA_TYPE_TEST_DE_BEGIN_KEY, testData));
+        expectedExtendedProperties.add(
+                new DataElement(DATA_TYPE_TEST_DE_END_KEY, testData));
+
+        Blefilter.PublicCredential credential =
+                Blefilter.PublicCredential.newBuilder()
+                        .setSecretId(ByteString.copyFrom(new byte[] {1}))
+                        .setAuthenticityKey(ByteString.copyFrom(new byte[2]))
+                        .setPublicKey(ByteString.copyFrom(new byte[3]))
+                        .setEncryptedMetadata(ByteString.copyFrom(new byte[4]))
+                        .setEncryptedMetadataTag(ByteString.copyFrom(new byte[5]))
+                        .build();
+        Blefilter.BleFilterResult result =
+                Blefilter.BleFilterResult.newBuilder()
+                        .setTxPower(2)
+                        .setRssi(1)
+                        .setBluetoothAddress(ByteString.copyFrom(bluetoothAddr))
+                        .setBleServiceData(ByteString.copyFrom(bleServiceData))
+                        .setPublicCredential(credential)
+                        .addDataElement(Blefilter.DataElement.newBuilder()
+                                .setKey(DATA_TYPE_CONNECTION_STATUS_KEY)
+                                .setValue(ByteString.copyFrom(connectionStatus))
+                                .setValueLength(connectionStatus.length)
+                        )
+                        .addDataElement(Blefilter.DataElement.newBuilder()
+                                .setKey(DATA_TYPE_BATTERY_KEY)
+                                .setValue(ByteString.copyFrom(batteryStatus))
+                                .setValueLength(batteryStatus.length)
+                        )
+                        .addDataElement(Blefilter.DataElement.newBuilder()
+                                .setKey(DATA_TYPE_FP_ACCOUNT_KEY)
+                                .setValue(ByteString.copyFrom(fastPairAccountKey))
+                                .setValueLength(fastPairAccountKey.length)
+                        )
+                        .addDataElement(Blefilter.DataElement.newBuilder()
+                                .setKey(DATA_TYPE_TEST_DE_BEGIN_KEY)
+                                .setValue(ByteString.copyFrom(testData))
+                                .setValueLength(testData.length)
+                        )
+                        .addDataElement(Blefilter.DataElement.newBuilder()
+                                .setKey(DATA_TYPE_TEST_DE_END_KEY)
+                                .setValue(ByteString.copyFrom(testData))
+                                .setValueLength(testData.length)
+                        )
+                        .build();
+        Blefilter.BleFilterResults results =
+                Blefilter.BleFilterResults.newBuilder().addResult(result).build();
+        NanoAppMessage chre_message =
+                NanoAppMessage.createMessageToNanoApp(
+                        ChreDiscoveryProvider.NANOAPP_ID,
+                        ChreDiscoveryProvider.NANOAPP_MESSAGE_TYPE_FILTER_RESULT,
+                        results.toByteArray());
+        mChreDiscoveryProvider.getController().setListener(mListener);
+        mChreDiscoveryProvider.init();
+        mChreDiscoveryProvider.onStart();
+        verify(mChreCommunication).start(mChreCallbackCaptor.capture(), any());
+        mChreCallbackCaptor.getValue().onMessageFromNanoApp(chre_message);
+        verify(mListener).onNearbyDeviceDiscovered(mNearbyDevice.capture());
+
+        List<DataElement> extendedProperties = PresenceDiscoveryResult
+                .fromDevice(mNearbyDevice.getValue()).getExtendedProperties();
+        assertThat(extendedProperties).containsExactlyElementsIn(expectedExtendedProperties);
+        // Reverts the setting of test app support
+        if (!isSupportedTestApp) {
+            DeviceConfig.setProperty(NAMESPACE, NEARBY_SUPPORT_TEST_APP, "false", false);
+            assertThat(new NearbyConfiguration().isTestAppSupported()).isFalse();
+        }
+    }
+
+    private boolean getDeviceConfigBoolean(final String name, final boolean defaultValue) {
+        final String value = getDeviceConfigProperty(name);
+        return value != null ? Boolean.parseBoolean(value) : defaultValue;
+    }
+
+    private String getDeviceConfigProperty(String name) {
+        return DeviceConfig.getProperty(NAMESPACE, name);
+    }
+
     private static class InLineExecutor implements Executor {
         @Override
         public void execute(Runnable command) {
diff --git a/nearby/tests/unit/src/com/android/server/nearby/util/ArrayUtilsTest.java b/nearby/tests/unit/src/com/android/server/nearby/util/ArrayUtilsTest.java
new file mode 100644
index 0000000..a759baf
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/util/ArrayUtilsTest.java
@@ -0,0 +1,80 @@
+/*
+ * 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.nearby.util;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import androidx.test.filters.SdkSuppress;
+
+import org.junit.Test;
+
+public final class ArrayUtilsTest {
+
+    private static final byte[] BYTES_ONE = new byte[] {7, 9};
+    private static final byte[] BYTES_TWO = new byte[] {8};
+    private static final byte[] BYTES_EMPTY = new byte[] {};
+    private static final byte[] BYTES_ALL = new byte[] {7, 9, 8};
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testConcatByteArraysNoInput() {
+        assertThat(ArrayUtils.concatByteArrays().length).isEqualTo(0);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testConcatByteArraysOneEmptyArray() {
+        assertThat(ArrayUtils.concatByteArrays(BYTES_EMPTY).length).isEqualTo(0);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testConcatByteArraysOneNonEmptyArray() {
+        assertThat(ArrayUtils.concatByteArrays(BYTES_ONE)).isEqualTo(BYTES_ONE);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testConcatByteArraysMultipleNonEmptyArrays() {
+        assertThat(ArrayUtils.concatByteArrays(BYTES_ONE, BYTES_TWO)).isEqualTo(BYTES_ALL);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testConcatByteArraysMultipleArrays() {
+        assertThat(ArrayUtils.concatByteArrays(BYTES_ONE, BYTES_EMPTY, BYTES_TWO))
+                .isEqualTo(BYTES_ALL);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testIsEmptyNull_returnsTrue() {
+        assertThat(ArrayUtils.isEmpty(null)).isTrue();
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testIsEmpty_returnsTrue() {
+        assertThat(ArrayUtils.isEmpty(new byte[]{})).isTrue();
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testIsEmpty_returnsFalse() {
+        assertThat(ArrayUtils.isEmpty(BYTES_ALL)).isFalse();
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/util/BroadcastPermissionsTest.java b/nearby/tests/unit/src/com/android/server/nearby/util/BroadcastPermissionsTest.java
index 1a22412..71ade2a 100644
--- a/nearby/tests/unit/src/com/android/server/nearby/util/BroadcastPermissionsTest.java
+++ b/nearby/tests/unit/src/com/android/server/nearby/util/BroadcastPermissionsTest.java
@@ -93,4 +93,9 @@
         assertThat(BroadcastPermissions.getPermissionLevel(mMockContext, UID, PID))
                 .isEqualTo(PERMISSION_BLUETOOTH_ADVERTISE);
     }
+
+    @Test
+    public void test_enforceBroadcastPermission() {
+        BroadcastPermissions.enforceBroadcastPermission(mMockContext, mCallerIdentity);
+    }
 }
diff --git a/nearby/tests/unit/src/com/android/server/nearby/util/encryption/CryptorImpIdentityV1Test.java b/nearby/tests/unit/src/com/android/server/nearby/util/encryption/CryptorImpIdentityV1Test.java
new file mode 100644
index 0000000..f0294fc
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/util/encryption/CryptorImpIdentityV1Test.java
@@ -0,0 +1,76 @@
+/*
+ * 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.nearby.util.encryption;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.util.Log;
+
+import org.junit.Test;
+
+import java.util.Arrays;
+
+public class CryptorImpIdentityV1Test {
+    private static final String TAG = "CryptorImpIdentityV1Test";
+    private static final byte[] SALT = new byte[] {102, 22};
+    private static final byte[] DATA =
+            new byte[] {107, -102, 101, 107, 20, 62, 2, 73, 113, 59, 8, -14, -58, 122};
+    private static final byte[] AUTHENTICITY_KEY =
+            new byte[] {-89, 88, -50, -42, -99, 57, 84, -24, 121, 1, -104, -8, -26, -73, -36, 100};
+
+    @Test
+    public void test_encrypt_decrypt() {
+        Cryptor identityCryptor = CryptorImpIdentityV1.getInstance();
+        byte[] encryptedData = identityCryptor.encrypt(DATA, SALT, AUTHENTICITY_KEY);
+
+        assertThat(identityCryptor.decrypt(encryptedData, SALT, AUTHENTICITY_KEY)).isEqualTo(DATA);
+    }
+
+    @Test
+    public void test_encryption() {
+        Cryptor identityCryptor = CryptorImpIdentityV1.getInstance();
+        byte[] encryptedData = identityCryptor.encrypt(DATA, SALT, AUTHENTICITY_KEY);
+
+        // for debugging
+        Log.d(TAG, "encrypted data is: " + Arrays.toString(encryptedData));
+
+        assertThat(encryptedData).isEqualTo(getEncryptedData());
+    }
+
+    @Test
+    public void test_decryption() {
+        Cryptor identityCryptor = CryptorImpIdentityV1.getInstance();
+        byte[] decryptedData =
+                identityCryptor.decrypt(getEncryptedData(), SALT, AUTHENTICITY_KEY);
+        // for debugging
+        Log.d(TAG, "decrypted data is: " + Arrays.toString(decryptedData));
+
+        assertThat(decryptedData).isEqualTo(DATA);
+    }
+
+    @Test
+    public void generateHmacTag() {
+        CryptorImpIdentityV1 identityCryptor = CryptorImpIdentityV1.getInstance();
+        byte[] generatedTag = identityCryptor.sign(DATA);
+        byte[] expectedTag = new byte[]{50, 116, 95, -87, 63, 123, -79, -43};
+        assertThat(generatedTag).isEqualTo(expectedTag);
+    }
+
+    private static byte[] getEncryptedData() {
+        return new byte[]{6, -31, -32, -123, 43, -92, -47, -110, -65, 126, -15, -51, -19, -43};
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/util/encryption/CryptorImpV1Test.java b/nearby/tests/unit/src/com/android/server/nearby/util/encryption/CryptorImpV1Test.java
new file mode 100644
index 0000000..3ca2575
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/util/encryption/CryptorImpV1Test.java
@@ -0,0 +1,114 @@
+/*
+ * 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.nearby.util.encryption;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.util.Log;
+
+import org.junit.Test;
+
+import java.util.Arrays;
+
+/**
+ * Unit test for {@link CryptorImpV1}
+ */
+public final class CryptorImpV1Test {
+    private static final String TAG = "CryptorImpV1Test";
+    private static final byte[] SALT = new byte[] {102, 22};
+    private static final byte[] DATA =
+            new byte[] {107, -102, 101, 107, 20, 62, 2, 73, 113, 59, 8, -14, -58, 122};
+    private static final byte[] AUTHENTICITY_KEY =
+            new byte[] {-89, 88, -50, -42, -99, 57, 84, -24, 121, 1, -104, -8, -26, -73, -36, 100};
+
+    @Test
+    public void test_encryption() {
+        Cryptor v1Cryptor = CryptorImpV1.getInstance();
+        byte[] encryptedData = v1Cryptor.encrypt(DATA, SALT, AUTHENTICITY_KEY);
+
+        // for debugging
+        Log.d(TAG, "encrypted data is: " + Arrays.toString(encryptedData));
+
+        assertThat(encryptedData).isEqualTo(getEncryptedData());
+    }
+
+    @Test
+    public void test_encryption_invalidInput() {
+        Cryptor v1Cryptor = CryptorImpV1.getInstance();
+        assertThat(v1Cryptor.encrypt(DATA, SALT, new byte[]{1, 2, 3, 4, 6})).isNull();
+    }
+
+    @Test
+    public void test_decryption() {
+        Cryptor v1Cryptor = CryptorImpV1.getInstance();
+        byte[] decryptedData =
+                v1Cryptor.decrypt(getEncryptedData(), SALT, AUTHENTICITY_KEY);
+        // for debugging
+        Log.d(TAG, "decrypted data is: " + Arrays.toString(decryptedData));
+
+        assertThat(decryptedData).isEqualTo(DATA);
+    }
+
+    @Test
+    public void test_decryption_invalidInput() {
+        Cryptor v1Cryptor = CryptorImpV1.getInstance();
+        assertThat(v1Cryptor.decrypt(getEncryptedData(), SALT, new byte[]{1, 2, 3, 4, 6})).isNull();
+    }
+
+    @Test
+    public void generateSign() {
+        CryptorImpV1 v1Cryptor = CryptorImpV1.getInstance();
+        byte[] generatedTag = v1Cryptor.sign(DATA, AUTHENTICITY_KEY);
+        byte[] expectedTag = new byte[]{
+                100, 88, -104, 80, -66, 107, -38, 95, 34, 40, -56, -23, -90, 90, -87, 12};
+        assertThat(generatedTag).isEqualTo(expectedTag);
+    }
+
+    @Test
+    public void test_verify() {
+        CryptorImpV1 v1Cryptor = CryptorImpV1.getInstance();
+        byte[] expectedTag = new byte[]{
+                100, 88, -104, 80, -66, 107, -38, 95, 34, 40, -56, -23, -90, 90, -87, 12};
+
+        assertThat(v1Cryptor.verify(DATA, AUTHENTICITY_KEY, expectedTag)).isTrue();
+        assertThat(v1Cryptor.verify(DATA, AUTHENTICITY_KEY, DATA)).isFalse();
+    }
+
+    @Test
+    public void test_generateHmacTag_sameResult() {
+        CryptorImpV1 v1Cryptor = CryptorImpV1.getInstance();
+        byte[] res1 = v1Cryptor.generateHmacTag(DATA, AUTHENTICITY_KEY);
+        assertThat(res1)
+                .isEqualTo(v1Cryptor.generateHmacTag(DATA, AUTHENTICITY_KEY));
+    }
+
+    @Test
+    public void test_generateHmacTag_nullData() {
+        CryptorImpV1 v1Cryptor = CryptorImpV1.getInstance();
+        assertThat(v1Cryptor.generateHmacTag(/* data= */ null, AUTHENTICITY_KEY)).isNull();
+    }
+
+    @Test
+    public void test_generateHmacTag_nullKey() {
+        CryptorImpV1 v1Cryptor = CryptorImpV1.getInstance();
+        assertThat(v1Cryptor.generateHmacTag(DATA, /* authenticityKey= */ null)).isNull();
+    }
+
+    private static byte[] getEncryptedData() {
+        return new byte[]{-92, 94, -99, -97, 81, -48, -7, 119, -64, -22, 45, -49, -50, 92};
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/util/encryption/CryptorTest.java b/nearby/tests/unit/src/com/android/server/nearby/util/encryption/CryptorTest.java
new file mode 100644
index 0000000..ca612e3
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/util/encryption/CryptorTest.java
@@ -0,0 +1,55 @@
+/*
+ * 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.nearby.util.encryption;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+
+/**
+ * Unit test for {@link Cryptor}
+ */
+public final class CryptorTest {
+
+    private static final byte[] DATA =
+            new byte[] {107, -102, 101, 107, 20, 62, 2, 73, 113, 59, 8, -14, -58, 122};
+    private static final byte[] AUTHENTICITY_KEY =
+            new byte[] {-89, 88, -50, -42, -99, 57, 84, -24, 121, 1, -104, -8, -26, -73, -36, 100};
+
+    @Test
+    public void test_computeHkdf() {
+        int outputSize = 16;
+        byte[] res1 = Cryptor.computeHkdf(DATA, AUTHENTICITY_KEY, outputSize);
+        byte[] res2 = Cryptor.computeHkdf(DATA,
+                new byte[] {-89, 88, -50, -42, -99, 57, 84, -24, 121, 1, -104, -8, -26},
+                outputSize);
+
+        assertThat(res1).hasLength(outputSize);
+        assertThat(res2).hasLength(outputSize);
+        assertThat(res1).isNotEqualTo(res2);
+        assertThat(res1)
+                .isEqualTo(CryptorImpV1.computeHkdf(DATA, AUTHENTICITY_KEY, outputSize));
+    }
+
+    @Test
+    public void test_computeHkdf_invalidInput() {
+        assertThat(Cryptor.computeHkdf(DATA, AUTHENTICITY_KEY, /* size= */ 256000))
+                .isNull();
+        assertThat(Cryptor.computeHkdf(DATA, new byte[0], /* size= */ 255))
+                .isNull();
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/util/identity/CallerIdentityTest.java b/nearby/tests/unit/src/com/android/server/nearby/util/identity/CallerIdentityTest.java
new file mode 100644
index 0000000..c29cb92
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/util/identity/CallerIdentityTest.java
@@ -0,0 +1,45 @@
+/*
+ * 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.nearby.util.identity;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+
+public class CallerIdentityTest {
+    private static final int UID = 100;
+    private static final int PID = 10002;
+    private static final String PACKAGE_NAME = "package_name";
+    private static final String ATTRIBUTION_TAG = "attribution_tag";
+
+    @Test
+    public void testToString() {
+        CallerIdentity callerIdentity =
+                CallerIdentity.forTest(UID, PID, PACKAGE_NAME, ATTRIBUTION_TAG);
+        assertThat(callerIdentity.toString()).isEqualTo("100/package_name[attribution_tag]");
+        assertThat(callerIdentity.isSystemServer()).isFalse();
+    }
+
+    @Test
+    public void testHashCode() {
+        CallerIdentity callerIdentity =
+                CallerIdentity.forTest(UID, PID, PACKAGE_NAME, ATTRIBUTION_TAG);
+        CallerIdentity callerIdentity1 =
+                CallerIdentity.forTest(UID, PID, PACKAGE_NAME, ATTRIBUTION_TAG);
+        assertThat(callerIdentity.hashCode()).isEqualTo(callerIdentity1.hashCode());
+    }
+}
diff --git a/netbpfload/Android.bp b/netbpfload/Android.bp
new file mode 100644
index 0000000..1f92374
--- /dev/null
+++ b/netbpfload/Android.bp
@@ -0,0 +1,51 @@
+//
+// 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.
+//
+
+cc_binary {
+    name: "netbpfload",
+
+    defaults: ["bpf_defaults"],
+    cflags: [
+        "-Wall",
+        "-Werror",
+        "-Wthread-safety",
+    ],
+    sanitize: {
+        integer_overflow: true,
+    },
+
+    header_libs: ["bpf_headers"],
+    shared_libs: [
+        "libbase",
+        "liblog",
+    ],
+    srcs: [
+        "loader.cpp",
+        "NetBpfLoad.cpp",
+    ],
+    apex_available: [
+        "com.android.tethering",
+        "//apex_available:platform",
+    ],
+    // really should be Android 14/U (34), but we cannot include binaries built
+    // against newer sdk in the apex, which still targets 30(R):
+    // 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"],
+}
diff --git a/netbpfload/NetBpfLoad.cpp b/netbpfload/NetBpfLoad.cpp
new file mode 100644
index 0000000..6152287
--- /dev/null
+++ b/netbpfload/NetBpfLoad.cpp
@@ -0,0 +1,275 @@
+/*
+ * Copyright (C) 2017-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.
+ */
+
+#ifndef LOG_TAG
+#define LOG_TAG "NetBpfLoad"
+#endif
+
+#include <arpa/inet.h>
+#include <dirent.h>
+#include <elf.h>
+#include <error.h>
+#include <fcntl.h>
+#include <inttypes.h>
+#include <linux/bpf.h>
+#include <linux/unistd.h>
+#include <net/if.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include <sys/mman.h>
+#include <sys/socket.h>
+#include <sys/stat.h>
+#include <sys/types.h>
+
+#include <android/api-level.h>
+#include <android-base/logging.h>
+#include <android-base/macros.h>
+#include <android-base/properties.h>
+#include <android-base/stringprintf.h>
+#include <android-base/strings.h>
+#include <android-base/unique_fd.h>
+#include <log/log.h>
+
+#include "BpfSyscallWrappers.h"
+#include "bpf/BpfUtils.h"
+#include "loader.h"
+
+using android::base::EndsWith;
+using android::bpf::domain;
+using std::string;
+
+bool exists(const char* const path) {
+    int v = access(path, F_OK);
+    if (!v) {
+        ALOGI("%s exists.", path);
+        return true;
+    }
+    if (errno == ENOENT) return false;
+    ALOGE("FATAL: access(%s, F_OK) -> %d [%d:%s]", path, v, errno, strerror(errno));
+    abort();  // can only hit this if permissions (likely selinux) are screwed up
+}
+
+
+const android::bpf::Location locations[] = {
+        // S+ Tethering mainline module (network_stack): tether offload
+        {
+                .dir = "/apex/com.android.tethering/etc/bpf/",
+                .prefix = "tethering/",
+        },
+        // T+ Tethering mainline module (shared with netd & system server)
+        // netutils_wrapper (for iptables xt_bpf) has access to programs
+        {
+                .dir = "/apex/com.android.tethering/etc/bpf/netd_shared/",
+                .prefix = "netd_shared/",
+        },
+        // T+ Tethering mainline module (shared with netd & system server)
+        // netutils_wrapper has no access, netd has read only access
+        {
+                .dir = "/apex/com.android.tethering/etc/bpf/netd_readonly/",
+                .prefix = "netd_readonly/",
+        },
+        // T+ Tethering mainline module (shared with system server)
+        {
+                .dir = "/apex/com.android.tethering/etc/bpf/net_shared/",
+                .prefix = "net_shared/",
+        },
+        // T+ Tethering mainline module (not shared, just network_stack)
+        {
+                .dir = "/apex/com.android.tethering/etc/bpf/net_private/",
+                .prefix = "net_private/",
+        },
+};
+
+int loadAllElfObjects(const android::bpf::Location& location) {
+    int retVal = 0;
+    DIR* dir;
+    struct dirent* ent;
+
+    if ((dir = opendir(location.dir)) != NULL) {
+        while ((ent = readdir(dir)) != NULL) {
+            string s = ent->d_name;
+            if (!EndsWith(s, ".o")) continue;
+
+            string progPath(location.dir);
+            progPath += s;
+
+            bool critical;
+            int ret = android::bpf::loadProg(progPath.c_str(), &critical, location);
+            if (ret) {
+                if (critical) retVal = ret;
+                ALOGE("Failed to load object: %s, ret: %s", progPath.c_str(), std::strerror(-ret));
+            } else {
+                ALOGI("Loaded object: %s", progPath.c_str());
+            }
+        }
+        closedir(dir);
+    }
+    return retVal;
+}
+
+int createSysFsBpfSubDir(const char* const prefix) {
+    if (*prefix) {
+        mode_t prevUmask = umask(0);
+
+        string s = "/sys/fs/bpf/";
+        s += prefix;
+
+        errno = 0;
+        int ret = mkdir(s.c_str(), S_ISVTX | S_IRWXU | S_IRWXG | S_IRWXO);
+        if (ret && errno != EEXIST) {
+            const int err = errno;
+            ALOGE("Failed to create directory: %s, ret: %s", s.c_str(), std::strerror(err));
+            return -err;
+        }
+
+        umask(prevUmask);
+    }
+    return 0;
+}
+
+// Technically 'value' doesn't need to be newline terminated, but it's best
+// to include a newline to match 'echo "value" > /proc/sys/...foo' behaviour,
+// which is usually how kernel devs test the actual sysctl interfaces.
+int writeProcSysFile(const char *filename, const char *value) {
+    android::base::unique_fd fd(open(filename, O_WRONLY | O_CLOEXEC));
+    if (fd < 0) {
+        const int err = errno;
+        ALOGE("open('%s', O_WRONLY | O_CLOEXEC) -> %s", filename, strerror(err));
+        return -err;
+    }
+    int len = strlen(value);
+    int v = write(fd, value, len);
+    if (v < 0) {
+        const int err = errno;
+        ALOGE("write('%s', '%s', %d) -> %s", filename, value, len, strerror(err));
+        return -err;
+    }
+    if (v != len) {
+        // In practice, due to us only using this for /proc/sys/... files, this can't happen.
+        ALOGE("write('%s', '%s', %d) -> short write [%d]", filename, value, len, v);
+        return -EINVAL;
+    }
+    return 0;
+}
+
+int main(int argc, char** argv, char * const envp[]) {
+    (void)argc;
+    android::base::InitLogging(argv, &android::base::KernelLogger);
+
+    const int device_api_level = android_get_device_api_level();
+    const bool isAtLeastU = (device_api_level >= __ANDROID_API_U__);
+
+    if (!android::bpf::isAtLeastKernelVersion(4, 19, 0)) {
+        ALOGE("Android U QPR2 requires kernel 4.19.");
+        return 1;
+    }
+
+    if (android::bpf::isUserspace32bit() && android::bpf::isAtLeastKernelVersion(6, 2, 0)) {
+        /* Android 14/U should only launch on 64-bit kernels
+         *   T launches on 5.10/5.15
+         *   U launches on 5.15/6.1
+         * So >=5.16 implies isKernel64Bit()
+         *
+         * We thus added a test to V VTS which requires 5.16+ devices to use 64-bit kernels.
+         *
+         * Starting with Android V, which is the first to support a post 6.1 Linux Kernel,
+         * we also require 64-bit userspace.
+         *
+         * There are various known issues with 32-bit userspace talking to various
+         * kernel interfaces (especially CAP_NET_ADMIN ones) on a 64-bit kernel.
+         * Some of these have userspace or kernel workarounds/hacks.
+         * Some of them don't...
+         * We're going to be removing the hacks.
+         *
+         * Additionally the 32-bit kernel jit support is poor,
+         * and 32-bit userspace on 64-bit kernel bpf ringbuffer compatibility is broken.
+         */
+        ALOGE("64-bit userspace required on 6.2+ kernels.");
+        return 1;
+    }
+
+    // Ensure we can determine the Android build type.
+    if (!android::bpf::isEng() && !android::bpf::isUser() && !android::bpf::isUserdebug()) {
+        ALOGE("Failed to determine the build type: got %s, want 'eng', 'user', or 'userdebug'",
+              android::bpf::getBuildType().c_str());
+        return 1;
+    }
+
+    if (isAtLeastU) {
+        // 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;
+
+        // 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
+        //  kernel does not have CONFIG_BPF_JIT=y)
+        // BPF_JIT is required by R VINTF (which means 4.14/4.19/5.4 kernels),
+        // but 4.14/4.19 were released with P & Q, and only 5.4 is new in R+.
+        if (writeProcSysFile("/proc/sys/net/core/bpf_jit_enable", "1\n")) return 1;
+
+        // Enable JIT kallsyms export for privileged users only
+        // (Note: this (open) will fail with ENOENT 'No such file or directory' if
+        //  kernel does not have CONFIG_HAVE_EBPF_JIT=y)
+        if (writeProcSysFile("/proc/sys/net/core/bpf_jit_kallsyms", "1\n")) return 1;
+    }
+
+    // Create all the pin subdirectories
+    // (this must be done first to allow selinux_context and pin_subdir functionality,
+    //  which could otherwise fail with ENOENT during object pinning or renaming,
+    //  due to ordering issues)
+    for (const auto& location : locations) {
+        if (createSysFsBpfSubDir(location.prefix)) return 1;
+    }
+
+    // Load all ELF objects, create programs and maps, and pin them
+    for (const auto& location : locations) {
+        if (loadAllElfObjects(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 "
+                  "problems or startup script race.");
+            ALOGE("--- DO NOT EXPECT SYSTEM TO BOOT SUCCESSFULLY ---");
+            sleep(20);
+            return 2;
+        }
+    }
+
+    int key = 1;
+    int value = 123;
+    android::base::unique_fd map(
+            android::bpf::createMap(BPF_MAP_TYPE_ARRAY, sizeof(key), sizeof(value), 2, 0));
+    if (android::bpf::writeToMapEntry(map, &key, &value, BPF_ANY)) {
+        ALOGE("Critical kernel bug - failure to write into index 1 of 2 element bpf map array.");
+        return 1;
+    }
+
+    ALOGI("done, transferring control to platform bpfloader.");
+
+    const char * args[] = { "/system/bin/bpfloader", NULL, };
+    if (execve(args[0], (char**)args, envp)) {
+        ALOGE("FATAL: execve('/system/bin/bpfloader'): %d[%s]", errno, strerror(errno));
+    }
+
+    return 1;
+}
diff --git a/netbpfload/initrc-doc/README.txt b/netbpfload/initrc-doc/README.txt
new file mode 100644
index 0000000..42e1fc2
--- /dev/null
+++ b/netbpfload/initrc-doc/README.txt
@@ -0,0 +1,62 @@
+This directory contains comment stripped versions of
+  //system/bpf/bpfloader/bpfloader.rc
+from previous versions of Android.
+
+Generated via:
+  (cd ../../../../../system/bpf && git cat-file -p remotes/aosp/android11-release:bpfloader/bpfloader.rc; ) | egrep -v '^ *#' > bpfloader-sdk30-11-R.rc
+  (cd ../../../../../system/bpf && git cat-file -p remotes/aosp/android12-release:bpfloader/bpfloader.rc; ) | egrep -v '^ *#' > bpfloader-sdk31-12-S.rc
+  (cd ../../../../../system/bpf && git cat-file -p remotes/aosp/android13-release:bpfloader/bpfloader.rc; ) | egrep -v '^ *#' > bpfloader-sdk33-13-T.rc
+  (cd ../../../../../system/bpf && git cat-file -p remotes/aosp/android14-release:bpfloader/bpfloader.rc; ) | egrep -v '^ *#' > bpfloader-sdk34-14-U.rc
+  (cd ../../../../../system/bpf && git cat-file -p remotes/aosp/main:bpfloader/bpfloader.rc;              ) | egrep -v '^ *#' > bpfloader-sdk34-14-U-QPR2.rc
+
+this is entirely equivalent to:
+  (cd /android1/system/bpf && git cat-file -p remotes/goog/rvc-dev:bpfloader/bpfloader.rc; ) | egrep -v '^ *#' > bpfloader-sdk30-11-R.rc
+  (cd /android1/system/bpf && git cat-file -p remotes/goog/sc-dev:bpfloader/bpfloader.rc;  ) | egrep -v '^ *#' > bpfloader-sdk31-12-S.rc
+  (cd /android1/system/bpf && git cat-file -p remotes/goog/tm-dev:bpfloader/bpfloader.rc;  ) | egrep -v '^ *#' > bpfloader-sdk33-13-T.rc
+  (cd /android1/system/bpf && git cat-file -p remotes/goog/udc-dev:bpfloader/bpfloader.rc; ) | egrep -v '^ *#' > bpfloader-sdk34-14-U.rc
+  (cd /android1/system/bpf && git cat-file -p remotes/goog/main:bpfloader/bpfloader.rc;    ) | egrep -v '^ *#' > bpfloader-sdk34-14-U-QPR2.rc
+
+it is also equivalent to:
+  (cd /android1/system/bpf && git cat-file -p remotes/goog/rvc-qpr-dev:bpfloader/bpfloader.rc; ) | egrep -v '^ *#' > bpfloader-sdk30-11-R.rc
+  (cd /android1/system/bpf && git cat-file -p remotes/goog/sc-v2-dev:bpfloader/bpfloader.rc;   ) | egrep -v '^ *#' > bpfloader-sdk31-12-S.rc
+  (cd /android1/system/bpf && git cat-file -p remotes/goog/tm-qpr-dev:bpfloader/bpfloader.rc;  ) | egrep -v '^ *#' > bpfloader-sdk33-13-T.rc
+  (cd /android1/system/bpf && git cat-file -p remotes/goog/udc-qpr-dev:bpfloader/bpfloader.rc; ) | egrep -v '^ *#' > bpfloader-sdk34-14-U.rc
+
+ie. there were no changes between R/S/T and R/S/T QPR3, and no change between U and U QPR1.
+
+Note: Sv2 sdk/api level is actually 32, it just didn't change anything wrt. bpf, so doesn't matter.
+
+
+Key takeaways:
+
+= R bpfloader:
+  - CHOWN + SYS_ADMIN
+  - asynchronous startup
+  - platform only
+  - proc file setup handled by initrc
+
+= S bpfloader
+  - adds NET_ADMIN
+  - synchronous startup
+  - platform + mainline tethering offload
+
+= T bpfloader
+  - platform + mainline networking (including tethering offload)
+  - supported btf for maps via exec of btfloader
+
+= U bpfloader
+  - proc file setup moved into bpfloader binary
+  - explicitly specified user and groups:
+    group root graphics network_stack net_admin net_bw_acct net_bw_stats net_raw system
+    user root
+
+= U QPR2 bpfloader
+  - drops support of btf for maps
+  - invocation of /system/bin/netbpfload binary, which after handling *all*
+    networking bpf related things executes the platform /system/bin/bpfloader
+    which handles non-networking bpf.
+
+Note that there is now a copy of 'netbpfload' provided by the tethering apex
+mainline module at /apex/com.android.tethering/bin/netbpfload, which due
+to the use of execve("/system/bin/bpfloader") relies on T+ selinux which was
+added for btf map support (specifically the ability to exec the "btfloader").
diff --git a/netbpfload/initrc-doc/bpfloader-sdk30-11-R.rc b/netbpfload/initrc-doc/bpfloader-sdk30-11-R.rc
new file mode 100644
index 0000000..482a7db
--- /dev/null
+++ b/netbpfload/initrc-doc/bpfloader-sdk30-11-R.rc
@@ -0,0 +1,11 @@
+on load_bpf_programs
+    write /proc/sys/net/core/bpf_jit_enable 1
+    write /proc/sys/net/core/bpf_jit_kallsyms 1
+    start bpfloader
+
+service bpfloader /system/bin/bpfloader
+    capabilities CHOWN SYS_ADMIN
+    rlimit memlock 1073741824 1073741824
+    oneshot
+    reboot_on_failure reboot,bpfloader-failed
+    updatable
diff --git a/netbpfload/initrc-doc/bpfloader-sdk31-12-S.rc b/netbpfload/initrc-doc/bpfloader-sdk31-12-S.rc
new file mode 100644
index 0000000..4117887
--- /dev/null
+++ b/netbpfload/initrc-doc/bpfloader-sdk31-12-S.rc
@@ -0,0 +1,11 @@
+on load_bpf_programs
+    write /proc/sys/net/core/bpf_jit_enable 1
+    write /proc/sys/net/core/bpf_jit_kallsyms 1
+    exec_start bpfloader
+
+service bpfloader /system/bin/bpfloader
+    capabilities CHOWN SYS_ADMIN NET_ADMIN
+    rlimit memlock 1073741824 1073741824
+    oneshot
+    reboot_on_failure reboot,bpfloader-failed
+    updatable
diff --git a/netbpfload/initrc-doc/bpfloader-sdk33-13-T.rc b/netbpfload/initrc-doc/bpfloader-sdk33-13-T.rc
new file mode 100644
index 0000000..f0b6700
--- /dev/null
+++ b/netbpfload/initrc-doc/bpfloader-sdk33-13-T.rc
@@ -0,0 +1,12 @@
+on load_bpf_programs
+    write /proc/sys/kernel/unprivileged_bpf_disabled 0
+    write /proc/sys/net/core/bpf_jit_enable 1
+    write /proc/sys/net/core/bpf_jit_kallsyms 1
+    exec_start bpfloader
+
+service bpfloader /system/bin/bpfloader
+    capabilities CHOWN SYS_ADMIN NET_ADMIN
+    rlimit memlock 1073741824 1073741824
+    oneshot
+    reboot_on_failure reboot,bpfloader-failed
+    updatable
diff --git a/netbpfload/initrc-doc/bpfloader-sdk34-14-U-QPR2.rc b/netbpfload/initrc-doc/bpfloader-sdk34-14-U-QPR2.rc
new file mode 100644
index 0000000..8f3f462
--- /dev/null
+++ b/netbpfload/initrc-doc/bpfloader-sdk34-14-U-QPR2.rc
@@ -0,0 +1,11 @@
+on load_bpf_programs
+    exec_start bpfloader
+
+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
diff --git a/netbpfload/initrc-doc/bpfloader-sdk34-14-U.rc b/netbpfload/initrc-doc/bpfloader-sdk34-14-U.rc
new file mode 100644
index 0000000..592303e
--- /dev/null
+++ b/netbpfload/initrc-doc/bpfloader-sdk34-14-U.rc
@@ -0,0 +1,11 @@
+on load_bpf_programs
+    exec_start bpfloader
+
+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
diff --git a/netbpfload/loader.cpp b/netbpfload/loader.cpp
new file mode 100644
index 0000000..c534b2c
--- /dev/null
+++ b/netbpfload/loader.cpp
@@ -0,0 +1,1185 @@
+/*
+ * Copyright (C) 2018-2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#define LOG_TAG "NetBpfLoader"
+
+#include <errno.h>
+#include <fcntl.h>
+#include <linux/bpf.h>
+#include <linux/elf.h>
+#include <log/log.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sysexits.h>
+#include <sys/stat.h>
+#include <sys/utsname.h>
+#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>
+#include <optional>
+#include <string>
+#include <unordered_map>
+#include <vector>
+
+#include <android-base/cmsg.h>
+#include <android-base/file.h>
+#include <android-base/properties.h>
+#include <android-base/strings.h>
+#include <android-base/unique_fd.h>
+
+#define BPF_FS_PATH "/sys/fs/bpf/"
+
+// Size of the BPF log buffer for verifier logging
+#define BPF_LOAD_LOG_SZ 0xfffff
+
+// Unspecified attach type is 0 which is BPF_CGROUP_INET_INGRESS.
+#define BPF_ATTACH_TYPE_UNSPEC BPF_CGROUP_INET_INGRESS
+
+using android::base::StartsWith;
+using android::base::unique_fd;
+using std::ifstream;
+using std::ios;
+using std::optional;
+using std::string;
+using std::vector;
+
+namespace android {
+namespace bpf {
+
+const std::string& getBuildType() {
+    static std::string t = android::base::GetProperty("ro.build.type", "unknown");
+    return t;
+}
+
+static unsigned int page_size = static_cast<unsigned int>(getpagesize());
+
+constexpr const char* lookupSelinuxContext(const domain d, const char* const unspecified = "") {
+    switch (d) {
+        case domain::unspecified:   return unspecified;
+        case domain::tethering:     return "fs_bpf_tethering";
+        case domain::net_private:   return "fs_bpf_net_private";
+        case domain::net_shared:    return "fs_bpf_net_shared";
+        case domain::netd_readonly: return "fs_bpf_netd_readonly";
+        case domain::netd_shared:   return "fs_bpf_netd_shared";
+        default:                    return "(unrecognized)";
+    }
+}
+
+domain getDomainFromSelinuxContext(const char s[BPF_SELINUX_CONTEXT_CHAR_ARRAY_SIZE]) {
+    for (domain d : AllDomains) {
+        // Not sure how to enforce this at compile time, so abort() bpfloader at boot instead
+        if (strlen(lookupSelinuxContext(d)) >= BPF_SELINUX_CONTEXT_CHAR_ARRAY_SIZE) abort();
+        if (!strncmp(s, lookupSelinuxContext(d), BPF_SELINUX_CONTEXT_CHAR_ARRAY_SIZE)) return d;
+    }
+    ALOGW("ignoring unrecognized selinux_context '%-32s'", s);
+    // We should return 'unrecognized' here, however: returning unspecified will
+    // result in the system simply using the default context, which in turn
+    // will allow future expansion by adding more restrictive selinux types.
+    // Older bpfloader will simply ignore that, and use the less restrictive default.
+    // This does mean you CANNOT later add a *less* restrictive type than the default.
+    //
+    // Note: we cannot just abort() here as this might be a mainline module shipped optional update
+    return domain::unspecified;
+}
+
+constexpr const char* lookupPinSubdir(const domain d, const char* const unspecified = "") {
+    switch (d) {
+        case domain::unspecified:   return unspecified;
+        case domain::tethering:     return "tethering/";
+        case domain::net_private:   return "net_private/";
+        case domain::net_shared:    return "net_shared/";
+        case domain::netd_readonly: return "netd_readonly/";
+        case domain::netd_shared:   return "netd_shared/";
+        default:                    return "(unrecognized)";
+    }
+};
+
+domain getDomainFromPinSubdir(const char s[BPF_PIN_SUBDIR_CHAR_ARRAY_SIZE]) {
+    for (domain d : AllDomains) {
+        // Not sure how to enforce this at compile time, so abort() bpfloader at boot instead
+        if (strlen(lookupPinSubdir(d)) >= BPF_PIN_SUBDIR_CHAR_ARRAY_SIZE) abort();
+        if (!strncmp(s, lookupPinSubdir(d), BPF_PIN_SUBDIR_CHAR_ARRAY_SIZE)) return d;
+    }
+    ALOGE("unrecognized pin_subdir '%-32s'", s);
+    // pin_subdir affects the object's full pathname,
+    // and thus using the default would change the location and thus our code's ability to find it,
+    // hence this seems worth treating as a true error condition.
+    //
+    // Note: we cannot just abort() here as this might be a mainline module shipped optional update
+    // However, our callers will treat this as an error, and stop loading the specific .o,
+    // which will fail bpfloader if the .o is marked critical.
+    return domain::unrecognized;
+}
+
+static string pathToObjName(const string& path) {
+    // extract everything after the final slash, ie. this is the filename 'foo@1.o' or 'bar.o'
+    string filename = android::base::Split(path, "/").back();
+    // strip off everything from the final period onwards (strip '.o' suffix), ie. 'foo@1' or 'bar'
+    string name = filename.substr(0, filename.find_last_of('.'));
+    // strip any potential @1 suffix, this will leave us with just 'foo' or 'bar'
+    // this can be used to provide duplicate programs (mux based on the bpfloader version)
+    return name.substr(0, name.find_last_of('@'));
+}
+
+typedef struct {
+    const char* name;
+    enum bpf_prog_type type;
+    enum bpf_attach_type expected_attach_type;
+} sectionType;
+
+/*
+ * Map section name prefixes to program types, the section name will be:
+ *   SECTION(<prefix>/<name-of-program>)
+ * For example:
+ *   SECTION("tracepoint/sched_switch_func") where sched_switch_funcs
+ * is the name of the program, and tracepoint is the type.
+ *
+ * However, be aware that you should not be directly using the SECTION() macro.
+ * Instead use the DEFINE_(BPF|XDP)_(PROG|MAP)... & LICENSE/CRITICAL macros.
+ *
+ * Programs shipped inside the tethering apex should be limited to networking stuff,
+ * as KPROBE, PERF_EVENT, TRACEPOINT are dangerous to use from mainline updatable code,
+ * since they are less stable abi/api and may conflict with platform uses of bpf.
+ */
+sectionType sectionNameTypes[] = {
+        {"bind4/",         BPF_PROG_TYPE_CGROUP_SOCK_ADDR, BPF_CGROUP_INET4_BIND},
+        {"bind6/",         BPF_PROG_TYPE_CGROUP_SOCK_ADDR, BPF_CGROUP_INET6_BIND},
+        {"cgroupskb/",     BPF_PROG_TYPE_CGROUP_SKB,       BPF_ATTACH_TYPE_UNSPEC},
+        {"cgroupsock/",    BPF_PROG_TYPE_CGROUP_SOCK,      BPF_ATTACH_TYPE_UNSPEC},
+        {"connect4/",      BPF_PROG_TYPE_CGROUP_SOCK_ADDR, BPF_CGROUP_INET4_CONNECT},
+        {"connect6/",      BPF_PROG_TYPE_CGROUP_SOCK_ADDR, BPF_CGROUP_INET6_CONNECT},
+        {"egress/",        BPF_PROG_TYPE_CGROUP_SKB,       BPF_CGROUP_INET_EGRESS},
+        {"getsockopt/",    BPF_PROG_TYPE_CGROUP_SOCKOPT,   BPF_CGROUP_GETSOCKOPT},
+        {"ingress/",       BPF_PROG_TYPE_CGROUP_SKB,       BPF_CGROUP_INET_INGRESS},
+        {"lwt_in/",        BPF_PROG_TYPE_LWT_IN,           BPF_ATTACH_TYPE_UNSPEC},
+        {"lwt_out/",       BPF_PROG_TYPE_LWT_OUT,          BPF_ATTACH_TYPE_UNSPEC},
+        {"lwt_seg6local/", BPF_PROG_TYPE_LWT_SEG6LOCAL,    BPF_ATTACH_TYPE_UNSPEC},
+        {"lwt_xmit/",      BPF_PROG_TYPE_LWT_XMIT,         BPF_ATTACH_TYPE_UNSPEC},
+        {"postbind4/",     BPF_PROG_TYPE_CGROUP_SOCK,      BPF_CGROUP_INET4_POST_BIND},
+        {"postbind6/",     BPF_PROG_TYPE_CGROUP_SOCK,      BPF_CGROUP_INET6_POST_BIND},
+        {"recvmsg4/",      BPF_PROG_TYPE_CGROUP_SOCK_ADDR, BPF_CGROUP_UDP4_RECVMSG},
+        {"recvmsg6/",      BPF_PROG_TYPE_CGROUP_SOCK_ADDR, BPF_CGROUP_UDP6_RECVMSG},
+        {"schedact/",      BPF_PROG_TYPE_SCHED_ACT,        BPF_ATTACH_TYPE_UNSPEC},
+        {"schedcls/",      BPF_PROG_TYPE_SCHED_CLS,        BPF_ATTACH_TYPE_UNSPEC},
+        {"sendmsg4/",      BPF_PROG_TYPE_CGROUP_SOCK_ADDR, BPF_CGROUP_UDP4_SENDMSG},
+        {"sendmsg6/",      BPF_PROG_TYPE_CGROUP_SOCK_ADDR, BPF_CGROUP_UDP6_SENDMSG},
+        {"setsockopt/",    BPF_PROG_TYPE_CGROUP_SOCKOPT,   BPF_CGROUP_SETSOCKOPT},
+        {"skfilter/",      BPF_PROG_TYPE_SOCKET_FILTER,    BPF_ATTACH_TYPE_UNSPEC},
+        {"sockops/",       BPF_PROG_TYPE_SOCK_OPS,         BPF_CGROUP_SOCK_OPS},
+        {"sysctl",         BPF_PROG_TYPE_CGROUP_SYSCTL,    BPF_CGROUP_SYSCTL},
+        {"xdp/",           BPF_PROG_TYPE_XDP,              BPF_ATTACH_TYPE_UNSPEC},
+};
+
+typedef struct {
+    enum bpf_prog_type type;
+    enum bpf_attach_type expected_attach_type;
+    string name;
+    vector<char> data;
+    vector<char> rel_data;
+    optional<struct bpf_prog_def> prog_def;
+
+    unique_fd prog_fd; /* fd after loading */
+} codeSection;
+
+static int readElfHeader(ifstream& elfFile, Elf64_Ehdr* eh) {
+    elfFile.seekg(0);
+    if (elfFile.fail()) return -1;
+
+    if (!elfFile.read((char*)eh, sizeof(*eh))) return -1;
+
+    return 0;
+}
+
+/* Reads all section header tables into an Shdr array */
+static int readSectionHeadersAll(ifstream& elfFile, vector<Elf64_Shdr>& shTable) {
+    Elf64_Ehdr eh;
+    int ret = 0;
+
+    ret = readElfHeader(elfFile, &eh);
+    if (ret) return ret;
+
+    elfFile.seekg(eh.e_shoff);
+    if (elfFile.fail()) return -1;
+
+    /* Read shdr table entries */
+    shTable.resize(eh.e_shnum);
+
+    if (!elfFile.read((char*)shTable.data(), (eh.e_shnum * eh.e_shentsize))) return -ENOMEM;
+
+    return 0;
+}
+
+/* Read a section by its index - for ex to get sec hdr strtab blob */
+static int readSectionByIdx(ifstream& elfFile, int id, vector<char>& sec) {
+    vector<Elf64_Shdr> shTable;
+    int ret = readSectionHeadersAll(elfFile, shTable);
+    if (ret) return ret;
+
+    elfFile.seekg(shTable[id].sh_offset);
+    if (elfFile.fail()) return -1;
+
+    sec.resize(shTable[id].sh_size);
+    if (!elfFile.read(sec.data(), shTable[id].sh_size)) return -1;
+
+    return 0;
+}
+
+/* Read whole section header string table */
+static int readSectionHeaderStrtab(ifstream& elfFile, vector<char>& strtab) {
+    Elf64_Ehdr eh;
+    int ret = readElfHeader(elfFile, &eh);
+    if (ret) return ret;
+
+    ret = readSectionByIdx(elfFile, eh.e_shstrndx, strtab);
+    if (ret) return ret;
+
+    return 0;
+}
+
+/* Get name from offset in strtab */
+static int getSymName(ifstream& elfFile, int nameOff, string& name) {
+    int ret;
+    vector<char> secStrTab;
+
+    ret = readSectionHeaderStrtab(elfFile, secStrTab);
+    if (ret) return ret;
+
+    if (nameOff >= (int)secStrTab.size()) return -1;
+
+    name = string((char*)secStrTab.data() + nameOff);
+    return 0;
+}
+
+/* Reads a full section by name - example to get the GPL license */
+static int readSectionByName(const char* name, ifstream& elfFile, vector<char>& data) {
+    vector<char> secStrTab;
+    vector<Elf64_Shdr> shTable;
+    int ret;
+
+    ret = readSectionHeadersAll(elfFile, shTable);
+    if (ret) return ret;
+
+    ret = readSectionHeaderStrtab(elfFile, secStrTab);
+    if (ret) return ret;
+
+    for (int i = 0; i < (int)shTable.size(); i++) {
+        char* secname = secStrTab.data() + shTable[i].sh_name;
+        if (!secname) continue;
+
+        if (!strcmp(secname, name)) {
+            vector<char> dataTmp;
+            dataTmp.resize(shTable[i].sh_size);
+
+            elfFile.seekg(shTable[i].sh_offset);
+            if (elfFile.fail()) return -1;
+
+            if (!elfFile.read((char*)dataTmp.data(), shTable[i].sh_size)) return -1;
+
+            data = dataTmp;
+            return 0;
+        }
+    }
+    return -2;
+}
+
+unsigned int readSectionUint(const char* name, ifstream& elfFile, unsigned int defVal) {
+    vector<char> theBytes;
+    int ret = readSectionByName(name, elfFile, theBytes);
+    if (ret) {
+        ALOGD("Couldn't find section %s (defaulting to %u [0x%x]).", name, defVal, defVal);
+        return defVal;
+    } else if (theBytes.size() < sizeof(unsigned int)) {
+        ALOGE("Section %s too short (defaulting to %u [0x%x]).", name, defVal, defVal);
+        return defVal;
+    } else {
+        // decode first 4 bytes as LE32 uint, there will likely be more bytes due to alignment.
+        unsigned int value = static_cast<unsigned char>(theBytes[3]);
+        value <<= 8;
+        value += static_cast<unsigned char>(theBytes[2]);
+        value <<= 8;
+        value += static_cast<unsigned char>(theBytes[1]);
+        value <<= 8;
+        value += static_cast<unsigned char>(theBytes[0]);
+        ALOGI("Section %s value is %u [0x%x]", name, value, value);
+        return value;
+    }
+}
+
+static int readSectionByType(ifstream& elfFile, int type, vector<char>& data) {
+    int ret;
+    vector<Elf64_Shdr> shTable;
+
+    ret = readSectionHeadersAll(elfFile, shTable);
+    if (ret) return ret;
+
+    for (int i = 0; i < (int)shTable.size(); i++) {
+        if ((int)shTable[i].sh_type != type) continue;
+
+        vector<char> dataTmp;
+        dataTmp.resize(shTable[i].sh_size);
+
+        elfFile.seekg(shTable[i].sh_offset);
+        if (elfFile.fail()) return -1;
+
+        if (!elfFile.read((char*)dataTmp.data(), shTable[i].sh_size)) return -1;
+
+        data = dataTmp;
+        return 0;
+    }
+    return -2;
+}
+
+static bool symCompare(Elf64_Sym a, Elf64_Sym b) {
+    return (a.st_value < b.st_value);
+}
+
+static int readSymTab(ifstream& elfFile, int sort, vector<Elf64_Sym>& data) {
+    int ret, numElems;
+    Elf64_Sym* buf;
+    vector<char> secData;
+
+    ret = readSectionByType(elfFile, SHT_SYMTAB, secData);
+    if (ret) return ret;
+
+    buf = (Elf64_Sym*)secData.data();
+    numElems = (secData.size() / sizeof(Elf64_Sym));
+    data.assign(buf, buf + numElems);
+
+    if (sort) std::sort(data.begin(), data.end(), symCompare);
+    return 0;
+}
+
+static enum bpf_prog_type getSectionType(string& name) {
+    for (auto& snt : sectionNameTypes)
+        if (StartsWith(name, snt.name)) return snt.type;
+
+    return BPF_PROG_TYPE_UNSPEC;
+}
+
+static enum bpf_attach_type getExpectedAttachType(string& name) {
+    for (auto& snt : sectionNameTypes)
+        if (StartsWith(name, snt.name)) return snt.expected_attach_type;
+    return BPF_ATTACH_TYPE_UNSPEC;
+}
+
+/*
+static string getSectionName(enum bpf_prog_type type)
+{
+    for (auto& snt : sectionNameTypes)
+        if (snt.type == type)
+            return string(snt.name);
+
+    return "UNKNOWN SECTION NAME " + std::to_string(type);
+}
+*/
+
+static int readProgDefs(ifstream& elfFile, vector<struct bpf_prog_def>& pd,
+                        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) {
+        ALOGE("readProgDefs failed due to improper sized progs section, %zu %% %zu != 0",
+              pdData.size(), sizeOfBpfProgDef);
+        return -1;
+    };
+
+    int progCount = pdData.size() / sizeOfBpfProgDef;
+    pd.resize(progCount);
+    size_t trimmedSize = std::min(sizeOfBpfProgDef, sizeof(struct bpf_prog_def));
+
+    const char* dataPtr = pdData.data();
+    for (auto& p : pd) {
+        // First we zero initialize
+        memset(&p, 0, sizeof(p));
+        // Then we set non-zero defaults
+        p.bpfloader_max_ver = DEFAULT_BPFLOADER_MAX_VER;  // v1.0
+        // Then we copy over the structure prefix from the ELF file.
+        memcpy(&p, dataPtr, trimmedSize);
+        // Move to next struct in the ELF file
+        dataPtr += sizeOfBpfProgDef;
+    }
+    return 0;
+}
+
+static int getSectionSymNames(ifstream& elfFile, const string& sectionName, vector<string>& names,
+                              optional<unsigned> symbolType = std::nullopt) {
+    int ret;
+    string name;
+    vector<Elf64_Sym> symtab;
+    vector<Elf64_Shdr> shTable;
+
+    ret = readSymTab(elfFile, 1 /* sort */, symtab);
+    if (ret) return ret;
+
+    /* Get index of section */
+    ret = readSectionHeadersAll(elfFile, shTable);
+    if (ret) return ret;
+
+    int sec_idx = -1;
+    for (int i = 0; i < (int)shTable.size(); i++) {
+        ret = getSymName(elfFile, shTable[i].sh_name, name);
+        if (ret) return ret;
+
+        if (!name.compare(sectionName)) {
+            sec_idx = i;
+            break;
+        }
+    }
+
+    /* No section found with matching name*/
+    if (sec_idx == -1) {
+        ALOGW("No %s section could be found in elf object", sectionName.c_str());
+        return -1;
+    }
+
+    for (int i = 0; i < (int)symtab.size(); i++) {
+        if (symbolType.has_value() && ELF_ST_TYPE(symtab[i].st_info) != symbolType) continue;
+
+        if (symtab[i].st_shndx == sec_idx) {
+            string s;
+            ret = getSymName(elfFile, symtab[i].st_name, s);
+            if (ret) return ret;
+            names.push_back(s);
+        }
+    }
+
+    return 0;
+}
+
+/* Read a section by its index - for ex to get sec hdr strtab blob */
+static int readCodeSections(ifstream& elfFile, vector<codeSection>& cs, size_t sizeOfBpfProgDef) {
+    vector<Elf64_Shdr> shTable;
+    int entries, ret = 0;
+
+    ret = readSectionHeadersAll(elfFile, shTable);
+    if (ret) return ret;
+    entries = shTable.size();
+
+    vector<struct bpf_prog_def> pd;
+    ret = readProgDefs(elfFile, pd, sizeOfBpfProgDef);
+    if (ret) return ret;
+    vector<string> progDefNames;
+    ret = getSectionSymNames(elfFile, "progs", progDefNames);
+    if (!pd.empty() && ret) return ret;
+
+    for (int i = 0; i < entries; i++) {
+        string name;
+        codeSection cs_temp;
+        cs_temp.type = BPF_PROG_TYPE_UNSPEC;
+
+        ret = getSymName(elfFile, shTable[i].sh_name, name);
+        if (ret) return ret;
+
+        enum bpf_prog_type ptype = getSectionType(name);
+
+        if (ptype == BPF_PROG_TYPE_UNSPEC) continue;
+
+        // This must be done before '/' is replaced with '_'.
+        cs_temp.expected_attach_type = getExpectedAttachType(name);
+
+        string oldName = name;
+
+        // convert all slashes to underscores
+        std::replace(name.begin(), name.end(), '/', '_');
+
+        cs_temp.type = ptype;
+        cs_temp.name = name;
+
+        ret = readSectionByIdx(elfFile, i, cs_temp.data);
+        if (ret) return ret;
+        ALOGD("Loaded code section %d (%s)", i, name.c_str());
+
+        vector<string> csSymNames;
+        ret = getSectionSymNames(elfFile, oldName, csSymNames, STT_FUNC);
+        if (ret || !csSymNames.size()) return ret;
+        for (size_t i = 0; i < progDefNames.size(); ++i) {
+            if (!progDefNames[i].compare(csSymNames[0] + "_def")) {
+                cs_temp.prog_def = pd[i];
+                break;
+            }
+        }
+
+        /* Check for rel section */
+        if (cs_temp.data.size() > 0 && i < entries) {
+            ret = getSymName(elfFile, shTable[i + 1].sh_name, name);
+            if (ret) return ret;
+
+            if (name == (".rel" + oldName)) {
+                ret = readSectionByIdx(elfFile, i + 1, cs_temp.rel_data);
+                if (ret) return ret;
+                ALOGD("Loaded relo section %d (%s)", i, name.c_str());
+            }
+        }
+
+        if (cs_temp.data.size() > 0) {
+            cs.push_back(std::move(cs_temp));
+            ALOGD("Adding section %d to cs list", i);
+        }
+    }
+    return 0;
+}
+
+static int getSymNameByIdx(ifstream& elfFile, int index, string& name) {
+    vector<Elf64_Sym> symtab;
+    int ret = 0;
+
+    ret = readSymTab(elfFile, 0 /* !sort */, symtab);
+    if (ret) return ret;
+
+    if (index >= (int)symtab.size()) return -1;
+
+    return getSymName(elfFile, symtab[index].st_name, name);
+}
+
+static bool mapMatchesExpectations(const unique_fd& fd, const string& mapName,
+                                   const struct bpf_map_def& mapDef, const enum bpf_map_type type) {
+    // 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),
+    // which should then cause (among others) a key_size mismatch.
+    int fd_type = bpfGetFdMapType(fd);
+    int fd_key_size = bpfGetFdKeySize(fd);
+    int fd_value_size = bpfGetFdValueSize(fd);
+    int fd_max_entries = bpfGetFdMaxEntries(fd);
+    int fd_map_flags = bpfGetFdMapFlags(fd);
+
+    // DEVMAPs are readonly from the bpf program side's point of view, as such
+    // the kernel in kernel/bpf/devmap.c dev_map_init_map() will set the flag
+    int desired_map_flags = (int)mapDef.map_flags;
+    if (type == BPF_MAP_TYPE_DEVMAP || type == BPF_MAP_TYPE_DEVMAP_HASH)
+        desired_map_flags |= BPF_F_RDONLY_PROG;
+
+    // The .h file enforces that this is a power of two, and page size will
+    // also always be a power of two, so this logic is actually enough to
+    // force it to be a multiple of the page size, as required by the kernel.
+    unsigned int desired_max_entries = mapDef.max_entries;
+    if (type == BPF_MAP_TYPE_RINGBUF) {
+        if (desired_max_entries < page_size) desired_max_entries = page_size;
+    }
+
+    // The following checks should *never* trigger, if one of them somehow does,
+    // it probably means a bpf .o file has been changed/replaced at runtime
+    // and bpfloader was manually rerun (normally it should only run *once*
+    // early during the boot process).
+    // Another possibility is that something is misconfigured in the code:
+    // most likely a shared map is declared twice differently.
+    // But such a change should never be checked into the source tree...
+    if ((fd_type == type) &&
+        (fd_key_size == (int)mapDef.key_size) &&
+        (fd_value_size == (int)mapDef.value_size) &&
+        (fd_max_entries == (int)desired_max_entries) &&
+        (fd_map_flags == desired_map_flags)) {
+        return true;
+    }
+
+    ALOGE("bpf map name %s mismatch: desired/found: "
+          "type:%d/%d key:%u/%d value:%u/%d entries:%u/%d flags:%u/%d",
+          mapName.c_str(), type, fd_type, mapDef.key_size, fd_key_size, mapDef.value_size,
+          fd_value_size, mapDef.max_entries, fd_max_entries, desired_map_flags, fd_map_flags);
+    return false;
+}
+
+static int createMaps(const char* elfPath, ifstream& elfFile, vector<unique_fd>& mapFds,
+                      const char* prefix, const size_t sizeOfBpfMapDef) {
+    int ret;
+    vector<char> mdData;
+    vector<struct bpf_map_def> md;
+    vector<string> mapNames;
+    string objName = pathToObjName(string(elfPath));
+
+    ret = readSectionByName("maps", elfFile, mdData);
+    if (ret == -2) return 0;  // no maps to read
+    if (ret) return ret;
+
+    if (mdData.size() % sizeOfBpfMapDef) {
+        ALOGE("createMaps failed due to improper sized maps section, %zu %% %zu != 0",
+              mdData.size(), sizeOfBpfMapDef);
+        return -1;
+    };
+
+    int mapCount = mdData.size() / sizeOfBpfMapDef;
+    md.resize(mapCount);
+    size_t trimmedSize = std::min(sizeOfBpfMapDef, sizeof(struct bpf_map_def));
+
+    const char* dataPtr = mdData.data();
+    for (auto& m : md) {
+        // First we zero initialize
+        memset(&m, 0, sizeof(m));
+        // Then we set non-zero defaults
+        m.bpfloader_max_ver = DEFAULT_BPFLOADER_MAX_VER;  // v1.0
+        m.max_kver = 0xFFFFFFFFu;                         // matches KVER_INF from bpf_helpers.h
+        // Then we copy over the structure prefix from the ELF file.
+        memcpy(&m, dataPtr, trimmedSize);
+        // Move to next struct in the ELF file
+        dataPtr += sizeOfBpfMapDef;
+    }
+
+    ret = getSectionSymNames(elfFile, "maps", mapNames);
+    if (ret) return ret;
+
+    unsigned kvers = kernelVersion();
+
+    for (int i = 0; i < (int)mapNames.size(); i++) {
+        if (md[i].zero != 0) abort();
+
+        if (BPFLOADER_VERSION < 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) {
+            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());
+            continue;
+        }
+
+        if (kvers < md[i].min_kver) {
+            ALOGI("skipping map %s which requires kernel version 0x%x >= 0x%x",
+                  mapNames[i].c_str(), kvers, md[i].min_kver);
+            mapFds.push_back(unique_fd());
+            continue;
+        }
+
+        if (kvers >= md[i].max_kver) {
+            ALOGI("skipping map %s which requires kernel version 0x%x < 0x%x",
+                  mapNames[i].c_str(), kvers, md[i].max_kver);
+            mapFds.push_back(unique_fd());
+            continue;
+        }
+
+        if ((md[i].ignore_on_eng && isEng()) || (md[i].ignore_on_user && isUser()) ||
+            (md[i].ignore_on_userdebug && isUserdebug())) {
+            ALOGI("skipping map %s which is ignored on %s builds", mapNames[i].c_str(),
+                  getBuildType().c_str());
+            mapFds.push_back(unique_fd());
+            continue;
+        }
+
+        if ((isArm() && isKernel32Bit() && md[i].ignore_on_arm32) ||
+            (isArm() && isKernel64Bit() && md[i].ignore_on_aarch64) ||
+            (isX86() && isKernel32Bit() && md[i].ignore_on_x86_32) ||
+            (isX86() && isKernel64Bit() && md[i].ignore_on_x86_64) ||
+            (isRiscV() && md[i].ignore_on_riscv64)) {
+            ALOGI("skipping map %s which is ignored on %s", mapNames[i].c_str(),
+                  describeArch());
+            mapFds.push_back(unique_fd());
+            continue;
+        }
+
+        enum bpf_map_type type = md[i].type;
+        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.
+            // However it cannot be used by ebpf programs in the same way.
+            // Since bpf_redirect_map() only requires 4.14, a program using a DEVMAP_HASH map
+            // would fail to load (due to trying to redirect to a HASH instead of DEVMAP_HASH).
+            // One must thus tag any BPF_MAP_TYPE_DEVMAP_HASH + bpf_redirect_map() using
+            // programs as being 5.4+...
+            type = BPF_MAP_TYPE_HASH;
+        }
+
+        // The .h file enforces that this is a power of two, and page size will
+        // also always be a power of two, so this logic is actually enough to
+        // force it to be a multiple of the page size, as required by the kernel.
+        unsigned int max_entries = md[i].max_entries;
+        if (type == BPF_MAP_TYPE_RINGBUF) {
+            if (max_entries < page_size) max_entries = page_size;
+        }
+
+        domain selinux_context = getDomainFromSelinuxContext(md[i].selinux_context);
+        if (specified(selinux_context)) {
+            ALOGI("map %s selinux_context [%-32s] -> %d -> '%s' (%s)", mapNames[i].c_str(),
+                  md[i].selinux_context, selinux_context, lookupSelinuxContext(selinux_context),
+                  lookupPinSubdir(selinux_context));
+        }
+
+        domain pin_subdir = getDomainFromPinSubdir(md[i].pin_subdir);
+        if (unrecognized(pin_subdir)) return -ENOTDIR;
+        if (specified(pin_subdir)) {
+            ALOGI("map %s pin_subdir [%-32s] -> %d -> '%s'", mapNames[i].c_str(), md[i].pin_subdir,
+                  pin_subdir, lookupPinSubdir(pin_subdir));
+        }
+
+        // Format of pin location is /sys/fs/bpf/<pin_subdir|prefix>map_<objName>_<mapName>
+        // except that maps shared across .o's have empty <objName>
+        // Note: <objName> refers to the extension-less basename of the .o file (without @ suffix).
+        string mapPinLoc = string(BPF_FS_PATH) + lookupPinSubdir(pin_subdir, prefix) + "map_" +
+                           (md[i].shared ? "" : objName) + "_" + mapNames[i];
+        bool reuse = false;
+        unique_fd fd;
+        int saved_errno;
+
+        if (access(mapPinLoc.c_str(), F_OK) == 0) {
+            fd.reset(mapRetrieveRO(mapPinLoc.c_str()));
+            saved_errno = errno;
+            ALOGD("bpf_create_map reusing map %s, ret: %d", mapNames[i].c_str(), fd.get());
+            reuse = true;
+        } else {
+            union bpf_attr req = {
+              .map_type = type,
+              .key_size = md[i].key_size,
+              .value_size = md[i].value_size,
+              .max_entries = max_entries,
+              .map_flags = md[i].map_flags,
+            };
+            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());
+        }
+
+        if (!fd.ok()) return -saved_errno;
+
+        // When reusing a pinned map, we need to check the map type/sizes/etc match, but for
+        // safety (since reuse code path is rare) run these checks even if we just created it.
+        // We assume failure is due to pinned map mismatch, hence the 'NOT UNIQUE' return code.
+        if (!mapMatchesExpectations(fd, mapNames[i], md[i], type)) return -ENOTUNIQ;
+
+        if (!reuse) {
+            if (specified(selinux_context)) {
+                string createLoc = string(BPF_FS_PATH) + lookupPinSubdir(selinux_context) +
+                                   "tmp_map_" + objName + "_" + mapNames[i];
+                ret = bpfFdPin(fd, createLoc.c_str());
+                if (ret) {
+                    int err = errno;
+                    ALOGE("create %s -> %d [%d:%s]", createLoc.c_str(), ret, err, strerror(err));
+                    return -err;
+                }
+                ret = renameat2(AT_FDCWD, createLoc.c_str(),
+                                AT_FDCWD, mapPinLoc.c_str(), RENAME_NOREPLACE);
+                if (ret) {
+                    int err = errno;
+                    ALOGE("rename %s %s -> %d [%d:%s]", createLoc.c_str(), mapPinLoc.c_str(), ret,
+                          err, strerror(err));
+                    return -err;
+                }
+            } else {
+                ret = bpfFdPin(fd, mapPinLoc.c_str());
+                if (ret) {
+                    int err = errno;
+                    ALOGE("pin %s -> %d [%d:%s]", mapPinLoc.c_str(), ret, err, strerror(err));
+                    return -err;
+                }
+            }
+            ret = chmod(mapPinLoc.c_str(), md[i].mode);
+            if (ret) {
+                int err = errno;
+                ALOGE("chmod(%s, 0%o) = %d [%d:%s]", mapPinLoc.c_str(), md[i].mode, ret, err,
+                      strerror(err));
+                return -err;
+            }
+            ret = chown(mapPinLoc.c_str(), (uid_t)md[i].uid, (gid_t)md[i].gid);
+            if (ret) {
+                int err = errno;
+                ALOGE("chown(%s, %u, %u) = %d [%d:%s]", mapPinLoc.c_str(), md[i].uid, md[i].gid,
+                      ret, err, strerror(err));
+                return -err;
+            }
+        }
+
+        int mapId = bpfGetFdMapId(fd);
+        if (mapId == -1) {
+            ALOGE("bpfGetFdMapId failed, ret: %d [%d]", mapId, errno);
+        } else {
+            ALOGI("map %s id %d", mapPinLoc.c_str(), mapId);
+        }
+
+        mapFds.push_back(std::move(fd));
+    }
+
+    return ret;
+}
+
+/* For debugging, dump all instructions */
+static void dumpIns(char* ins, int size) {
+    for (int row = 0; row < size / 8; row++) {
+        ALOGE("%d: ", row);
+        for (int j = 0; j < 8; j++) {
+            ALOGE("%3x ", ins[(row * 8) + j]);
+        }
+        ALOGE("\n");
+    }
+}
+
+/* For debugging, dump all code sections from cs list */
+static void dumpAllCs(vector<codeSection>& cs) {
+    for (int i = 0; i < (int)cs.size(); i++) {
+        ALOGE("Dumping cs %d, name %s", int(i), cs[i].name.c_str());
+        dumpIns((char*)cs[i].data.data(), cs[i].data.size());
+        ALOGE("-----------");
+    }
+}
+
+static void applyRelo(void* insnsPtr, Elf64_Addr offset, int fd) {
+    int insnIndex;
+    struct bpf_insn *insn, *insns;
+
+    insns = (struct bpf_insn*)(insnsPtr);
+
+    insnIndex = offset / sizeof(struct bpf_insn);
+    insn = &insns[insnIndex];
+
+    // Occasionally might be useful for relocation debugging, but pretty spammy
+    if (0) {
+        ALOGD("applying relo to instruction at byte offset: %llu, "
+              "insn offset %d, insn %llx",
+              (unsigned long long)offset, insnIndex, *(unsigned long long*)insn);
+    }
+
+    if (insn->code != (BPF_LD | BPF_IMM | BPF_DW)) {
+        ALOGE("Dumping all instructions till ins %d", insnIndex);
+        ALOGE("invalid relo for insn %d: code 0x%x", insnIndex, insn->code);
+        dumpIns((char*)insnsPtr, (insnIndex + 3) * 8);
+        return;
+    }
+
+    insn->imm = fd;
+    insn->src_reg = BPF_PSEUDO_MAP_FD;
+}
+
+static void applyMapRelo(ifstream& elfFile, vector<unique_fd> &mapFds, vector<codeSection>& cs) {
+    vector<string> mapNames;
+
+    int ret = getSectionSymNames(elfFile, "maps", mapNames);
+    if (ret) return;
+
+    for (int k = 0; k != (int)cs.size(); k++) {
+        Elf64_Rel* rel = (Elf64_Rel*)(cs[k].rel_data.data());
+        int n_rel = cs[k].rel_data.size() / sizeof(*rel);
+
+        for (int i = 0; i < n_rel; i++) {
+            int symIndex = ELF64_R_SYM(rel[i].r_info);
+            string symName;
+
+            ret = getSymNameByIdx(elfFile, symIndex, symName);
+            if (ret) return;
+
+            /* Find the map fd and apply relo */
+            for (int j = 0; j < (int)mapNames.size(); j++) {
+                if (!mapNames[j].compare(symName)) {
+                    applyRelo(cs[k].data.data(), rel[i].r_offset, mapFds[j]);
+                    break;
+                }
+            }
+        }
+    }
+}
+
+static int loadCodeSections(const char* elfPath, vector<codeSection>& cs, const string& license,
+                            const char* prefix) {
+    unsigned kvers = kernelVersion();
+
+    if (!kvers) {
+        ALOGE("unable to get kernel version");
+        return -EINVAL;
+    }
+
+    string objName = pathToObjName(string(elfPath));
+
+    for (int i = 0; i < (int)cs.size(); i++) {
+        unique_fd& fd = cs[i].prog_fd;
+        int ret;
+        string name = cs[i].name;
+
+        if (!cs[i].prog_def.has_value()) {
+            ALOGE("[%d] '%s' missing program definition! bad bpf.o build?", i, name.c_str());
+            return -EINVAL;
+        }
+
+        unsigned min_kver = cs[i].prog_def->min_kver;
+        unsigned max_kver = cs[i].prog_def->max_kver;
+        ALOGD("cs[%d].name:%s min_kver:%x .max_kver:%x (kvers:%x)", i, name.c_str(), min_kver,
+             max_kver, kvers);
+        if (kvers < min_kver) continue;
+        if (kvers >= max_kver) continue;
+
+        unsigned bpfMinVer = cs[i].prog_def->bpfloader_min_ver;
+        unsigned bpfMaxVer = cs[i].prog_def->bpfloader_max_ver;
+        domain selinux_context = getDomainFromSelinuxContext(cs[i].prog_def->selinux_context);
+        domain pin_subdir = getDomainFromPinSubdir(cs[i].prog_def->pin_subdir);
+        // Note: make sure to only check for unrecognized *after* verifying bpfloader
+        // version limits include this bpfloader's version.
+
+        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 ((cs[i].prog_def->ignore_on_eng && isEng()) ||
+            (cs[i].prog_def->ignore_on_user && isUser()) ||
+            (cs[i].prog_def->ignore_on_userdebug && isUserdebug())) {
+            ALOGD("cs[%d].name:%s is ignored on %s builds", i, name.c_str(),
+                  getBuildType().c_str());
+            continue;
+        }
+
+        if ((isArm() && isKernel32Bit() && cs[i].prog_def->ignore_on_arm32) ||
+            (isArm() && isKernel64Bit() && cs[i].prog_def->ignore_on_aarch64) ||
+            (isX86() && isKernel32Bit() && cs[i].prog_def->ignore_on_x86_32) ||
+            (isX86() && isKernel64Bit() && cs[i].prog_def->ignore_on_x86_64) ||
+            (isRiscV() && cs[i].prog_def->ignore_on_riscv64)) {
+            ALOGD("cs[%d].name:%s is ignored on %s", i, name.c_str(), describeArch());
+            continue;
+        }
+
+        if (unrecognized(pin_subdir)) return -ENOTDIR;
+
+        if (specified(selinux_context)) {
+            ALOGI("prog %s selinux_context [%-32s] -> %d -> '%s' (%s)", name.c_str(),
+                  cs[i].prog_def->selinux_context, selinux_context,
+                  lookupSelinuxContext(selinux_context), lookupPinSubdir(selinux_context));
+        }
+
+        if (specified(pin_subdir)) {
+            ALOGI("prog %s pin_subdir [%-32s] -> %d -> '%s'", name.c_str(),
+                  cs[i].prog_def->pin_subdir, pin_subdir, lookupPinSubdir(pin_subdir));
+        }
+
+        // strip any potential $foo suffix
+        // this can be used to provide duplicate programs
+        // conditionally loaded based on running kernel version
+        name = name.substr(0, name.find_last_of('$'));
+
+        bool reuse = false;
+        // Format of pin location is
+        // /sys/fs/bpf/<prefix>prog_<objName>_<progName>
+        string progPinLoc = string(BPF_FS_PATH) + lookupPinSubdir(pin_subdir, prefix) + "prog_" +
+                            objName + '_' + string(name);
+        if (access(progPinLoc.c_str(), F_OK) == 0) {
+            fd.reset(retrieveProgram(progPinLoc.c_str()));
+            ALOGD("New bpf prog load reusing prog %s, ret: %d (%s)", progPinLoc.c_str(), fd.get(),
+                  (!fd.ok() ? std::strerror(errno) : "no error"));
+            reuse = true;
+        } else {
+            vector<char> log_buf(BPF_LOAD_LOG_SZ, 0);
+
+            union bpf_attr req = {
+              .prog_type = cs[i].type,
+              .kern_version = kvers,
+              .license = ptr_to_u64(license.c_str()),
+              .insns = ptr_to_u64(cs[i].data.data()),
+              .insn_cnt = static_cast<__u32>(cs[i].data.size() / sizeof(struct bpf_insn)),
+              .log_level = 1,
+              .log_buf = ptr_to_u64(log_buf.data()),
+              .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));
+            fd.reset(bpf(BPF_PROG_LOAD, req));
+
+            ALOGD("BPF_PROG_LOAD call for %s (%s) returned fd: %d (%s)", elfPath,
+                  cs[i].name.c_str(), fd.get(), (!fd.ok() ? std::strerror(errno) : "no error"));
+
+            if (!fd.ok()) {
+                vector<string> lines = android::base::Split(log_buf.data(), "\n");
+
+                ALOGW("BPF_PROG_LOAD - BEGIN log_buf contents:");
+                for (const auto& line : lines) ALOGW("%s", line.c_str());
+                ALOGW("BPF_PROG_LOAD - END log_buf contents.");
+
+                if (cs[i].prog_def->optional) {
+                    ALOGW("failed program is marked optional - continuing...");
+                    continue;
+                }
+                ALOGE("non-optional program failed to load.");
+            }
+        }
+
+        if (!fd.ok()) return fd.get();
+
+        if (!reuse) {
+            if (specified(selinux_context)) {
+                string createLoc = string(BPF_FS_PATH) + lookupPinSubdir(selinux_context) +
+                                   "tmp_prog_" + objName + '_' + string(name);
+                ret = bpfFdPin(fd, createLoc.c_str());
+                if (ret) {
+                    int err = errno;
+                    ALOGE("create %s -> %d [%d:%s]", createLoc.c_str(), ret, err, strerror(err));
+                    return -err;
+                }
+                ret = renameat2(AT_FDCWD, createLoc.c_str(),
+                                AT_FDCWD, progPinLoc.c_str(), RENAME_NOREPLACE);
+                if (ret) {
+                    int err = errno;
+                    ALOGE("rename %s %s -> %d [%d:%s]", createLoc.c_str(), progPinLoc.c_str(), ret,
+                          err, strerror(err));
+                    return -err;
+                }
+            } else {
+                ret = bpfFdPin(fd, progPinLoc.c_str());
+                if (ret) {
+                    int err = errno;
+                    ALOGE("create %s -> %d [%d:%s]", progPinLoc.c_str(), ret, err, strerror(err));
+                    return -err;
+                }
+            }
+            if (chmod(progPinLoc.c_str(), 0440)) {
+                int err = errno;
+                ALOGE("chmod %s 0440 -> [%d:%s]", progPinLoc.c_str(), err, strerror(err));
+                return -err;
+            }
+            if (chown(progPinLoc.c_str(), (uid_t)cs[i].prog_def->uid,
+                      (gid_t)cs[i].prog_def->gid)) {
+                int err = errno;
+                ALOGE("chown %s %d %d -> [%d:%s]", progPinLoc.c_str(), cs[i].prog_def->uid,
+                      cs[i].prog_def->gid, err, strerror(err));
+                return -err;
+            }
+        }
+
+        int progId = bpfGetFdProgId(fd);
+        if (progId == -1) {
+            ALOGE("bpfGetFdProgId failed, ret: %d [%d]", progId, errno);
+        } else {
+            ALOGI("prog %s id %d", progPinLoc.c_str(), progId);
+        }
+    }
+
+    return 0;
+}
+
+int loadProg(const char* elfPath, bool* isCritical, const Location& location) {
+    vector<char> license;
+    vector<char> critical;
+    vector<codeSection> cs;
+    vector<unique_fd> mapFds;
+    int ret;
+
+    if (!isCritical) return -1;
+    *isCritical = false;
+
+    ifstream elfFile(elfPath, ios::in | ios::binary);
+    if (!elfFile.is_open()) return -1;
+
+    ret = readSectionByName("critical", elfFile, critical);
+    *isCritical = !ret;
+
+    ret = readSectionByName("license", elfFile, license);
+    if (ret) {
+        ALOGE("Couldn't find license in %s", elfPath);
+        return ret;
+    } else {
+        ALOGD("Loading %s%s ELF object %s with license %s",
+              *isCritical ? "critical for " : "optional", *isCritical ? (char*)critical.data() : "",
+              elfPath, (char*)license.data());
+    }
+
+    // the following default values are for bpfloader V0.0 format which does not include them
+    unsigned int bpfLoaderMinVer =
+            readSectionUint("bpfloader_min_ver", elfFile, DEFAULT_BPFLOADER_MIN_VER);
+    unsigned int bpfLoaderMaxVer =
+            readSectionUint("bpfloader_max_ver", elfFile, DEFAULT_BPFLOADER_MAX_VER);
+    unsigned int bpfLoaderMinRequiredVer =
+            readSectionUint("bpfloader_min_required_ver", elfFile, 0);
+    size_t sizeOfBpfMapDef =
+            readSectionUint("size_of_bpf_map_def", elfFile, DEFAULT_SIZEOF_BPF_MAP_DEF);
+    size_t sizeOfBpfProgDef =
+            readSectionUint("size_of_bpf_prog_def", elfFile, DEFAULT_SIZEOF_BPF_PROG_DEF);
+
+    // inclusive lower bound check
+    if (BPFLOADER_VERSION < bpfLoaderMinVer) {
+        ALOGI("BpfLoader version 0x%05x ignoring ELF object %s with min ver 0x%05x",
+              BPFLOADER_VERSION, elfPath, bpfLoaderMinVer);
+        return 0;
+    }
+
+    // exclusive upper bound check
+    if (BPFLOADER_VERSION >= bpfLoaderMaxVer) {
+        ALOGI("BpfLoader version 0x%05x ignoring ELF object %s with max ver 0x%05x",
+              BPFLOADER_VERSION, elfPath, bpfLoaderMaxVer);
+        return 0;
+    }
+
+    if (BPFLOADER_VERSION < bpfLoaderMinRequiredVer) {
+        ALOGI("BpfLoader version 0x%05x failing due to ELF object %s with required min ver 0x%05x",
+              BPFLOADER_VERSION, elfPath, bpfLoaderMinRequiredVer);
+        return -1;
+    }
+
+    ALOGI("BpfLoader version 0x%05x processing ELF object %s with ver [0x%05x,0x%05x)",
+          BPFLOADER_VERSION, elfPath, bpfLoaderMinVer, bpfLoaderMaxVer);
+
+    if (sizeOfBpfMapDef < DEFAULT_SIZEOF_BPF_MAP_DEF) {
+        ALOGE("sizeof(bpf_map_def) of %zu is too small (< %d)", sizeOfBpfMapDef,
+              DEFAULT_SIZEOF_BPF_MAP_DEF);
+        return -1;
+    }
+
+    if (sizeOfBpfProgDef < DEFAULT_SIZEOF_BPF_PROG_DEF) {
+        ALOGE("sizeof(bpf_prog_def) of %zu is too small (< %d)", sizeOfBpfProgDef,
+              DEFAULT_SIZEOF_BPF_PROG_DEF);
+        return -1;
+    }
+
+    ret = readCodeSections(elfFile, cs, sizeOfBpfProgDef);
+    if (ret) {
+        ALOGE("Couldn't read all code sections in %s", elfPath);
+        return ret;
+    }
+
+    /* Just for future debugging */
+    if (0) dumpAllCs(cs);
+
+    ret = createMaps(elfPath, elfFile, mapFds, location.prefix, sizeOfBpfMapDef);
+    if (ret) {
+        ALOGE("Failed to create maps: (ret=%d) in %s", ret, elfPath);
+        return ret;
+    }
+
+    for (int i = 0; i < (int)mapFds.size(); i++)
+        ALOGD("map_fd found at %d is %d in %s", i, mapFds[i].get(), elfPath);
+
+    applyMapRelo(elfFile, mapFds, cs);
+
+    ret = loadCodeSections(elfPath, cs, string(license.data()), location.prefix);
+    if (ret) ALOGE("Failed to load programs, loadCodeSections ret=%d", ret);
+
+    return ret;
+}
+
+}  // namespace bpf
+}  // namespace android
diff --git a/netbpfload/loader.h b/netbpfload/loader.h
new file mode 100644
index 0000000..b884637
--- /dev/null
+++ b/netbpfload/loader.h
@@ -0,0 +1,93 @@
+/*
+ * Copyright (C) 2018-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.
+ */
+
+#pragma once
+
+#include <linux/bpf.h>
+
+#include <fstream>
+
+namespace android {
+namespace bpf {
+
+// Bpf programs may specify per-program & per-map selinux_context and pin_subdir.
+//
+// The BpfLoader needs to convert these bpf.o specified strings into an enum
+// for internal use (to check that valid values were specified for the specific
+// location of the bpf.o file).
+//
+// It also needs to map selinux_context's into pin_subdir's.
+// This is because of how selinux_context is actually implemented via pin+rename.
+//
+// Thus 'domain' enumerates all selinux_context's/pin_subdir's that the BpfLoader
+// is aware of.  Thus there currently needs to be a 1:1 mapping between the two.
+//
+enum class domain : int {
+    unrecognized = -1,  // invalid for this version of the bpfloader
+    unspecified = 0,    // means just use the default for that specific pin location
+    tethering,          // (S+) fs_bpf_tethering     /sys/fs/bpf/tethering
+    net_private,        // (T+) fs_bpf_net_private   /sys/fs/bpf/net_private
+    net_shared,         // (T+) fs_bpf_net_shared    /sys/fs/bpf/net_shared
+    netd_readonly,      // (T+) fs_bpf_netd_readonly /sys/fs/bpf/netd_readonly
+    netd_shared,        // (T+) fs_bpf_netd_shared   /sys/fs/bpf/netd_shared
+};
+
+// Note: this does not include domain::unrecognized, but does include domain::unspecified
+static constexpr domain AllDomains[] = {
+    domain::unspecified,
+    domain::tethering,
+    domain::net_private,
+    domain::net_shared,
+    domain::netd_readonly,
+    domain::netd_shared,
+};
+
+static constexpr bool unrecognized(domain d) {
+    return d == domain::unrecognized;
+}
+
+// Note: this doesn't handle unrecognized, handle it first.
+static constexpr bool specified(domain d) {
+    return d != domain::unspecified;
+}
+
+struct Location {
+    const char* const dir = "";
+    const char* const prefix = "";
+};
+
+// BPF loader implementation. Loads an eBPF ELF object
+int loadProg(const char* elfPath, bool* isCritical, const Location &location = {});
+
+// Exposed for testing
+unsigned int readSectionUint(const char* name, std::ifstream& elfFile, unsigned int defVal);
+
+// Returns the build type string (from ro.build.type).
+const std::string& getBuildType();
+
+// The following functions classify the 3 Android build types.
+inline bool isEng() {
+    return getBuildType() == "eng";
+}
+inline bool isUser() {
+    return getBuildType() == "user";
+}
+inline bool isUserdebug() {
+    return getBuildType() == "userdebug";
+}
+
+}  // namespace bpf
+}  // namespace android
diff --git a/netbpfload/netbpfload.rc b/netbpfload/netbpfload.rc
new file mode 100644
index 0000000..14181dc
--- /dev/null
+++ b/netbpfload/netbpfload.rc
@@ -0,0 +1,86 @@
+# zygote-start is what officially starts netd (see //system/core/rootdir/init.rc)
+# However, on some hardware it's started from post-fs-data as well, which is just
+# a tad earlier.  There's no benefit to that though, since on 4.9+ P+ devices netd
+# will just block until bpfloader finishes and sets the bpf.progs_loaded property.
+#
+# It is important that we start bpfloader after:
+#   - /sys/fs/bpf is already mounted,
+#   - apex (incl. rollback) is initialized (so that in the future we can load bpf
+#     programs shipped as part of apex mainline modules)
+#   - logd is ready for us to log stuff
+#
+# At the same time we want to be as early as possible to reduce races and thus
+# failures (before memory is fragmented, and cpu is busy running tons of other
+# stuff) and we absolutely want to be before netd and the system boot slot is
+# considered to have booted successfully.
+#
+on load_bpf_programs
+    exec_start bpfloader
+
+service bpfloader /system/bin/netbpfload
+    # netbpfload will do network bpf loading, then execute /system/bin/bpfloader
+    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
+    user root
+    #
+    # Set RLIMIT_MEMLOCK to 1GiB for bpfloader
+    #
+    # Actually only 8MiB would be needed if bpfloader ran as its own uid.
+    #
+    # However, while the rlimit is per-thread, the accounting is system wide.
+    # So, for example, if the graphics stack has already allocated 10MiB of
+    # memlock data before bpfloader even gets a chance to run, it would fail
+    # if its memlock rlimit is only 8MiB - since there would be none left for it.
+    #
+    # bpfloader succeeding is critical to system health, since a failure will
+    # cause netd crashloop and thus system server crashloop... and the only
+    # recovery is a full kernel reboot.
+    #
+    # We've had issues where devices would sometimes (rarely) boot into
+    # a crashloop because bpfloader would occasionally lose a boot time
+    # race against the graphics stack's boot time locked memory allocation.
+    #
+    # Thus bpfloader's memlock has to be 8MB higher then the locked memory
+    # consumption of the root uid anywhere else in the system...
+    # But we don't know what that is for all possible devices...
+    #
+    # Ideally, we'd simply grant bpfloader the IPC_LOCK capability and it
+    # would simply ignore it's memlock rlimit... but it turns that this
+    # capability is not even checked by the kernel's bpf system call.
+    #
+    # As such we simply use 1GiB as a reasonable approximation of infinity.
+    #
+    rlimit memlock 1073741824 1073741824
+    oneshot
+    #
+    # How to debug bootloops caused by 'bpfloader-failed'.
+    #
+    # 1. On some lower RAM devices (like wembley) you may need to first enable developer mode
+    #    (from the Settings app UI), and change the developer option "Logger buffer sizes"
+    #    from the default (wembley: 64kB) to the maximum (1M) per log buffer.
+    #    Otherwise buffer will overflow before you manage to dump it and you'll get useless logs.
+    #
+    # 2. comment out 'reboot_on_failure reboot,bpfloader-failed' below
+    # 3. rebuild/reflash/reboot
+    # 4. as the device is booting up capture bpfloader logs via:
+    #    adb logcat -s 'bpfloader:*' 'LibBpfLoader:*' 'NetBpfLoad:*' 'NetBpfLoader:*'
+    #
+    # something like:
+    #   $ adb reboot; sleep 1; adb wait-for-device; adb root; sleep 1; adb wait-for-device; adb logcat -s 'bpfloader:*' 'LibBpfLoader:*' 'NetBpfLoad:*' 'NetBpfLoader:*'
+    # will take care of capturing logs as early as possible
+    #
+    # 5. look through the logs from the kernel's bpf verifier that bpfloader dumps out,
+    #    it usually makes sense to search back from the end and find the particular
+    #    bpf verifier failure that caused bpfloader to terminate early with an error code.
+    #    This will probably be something along the lines of 'too many jumps' or
+    #    '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
+    updatable
diff --git a/netd/BpfHandler.cpp b/netd/BpfHandler.cpp
index fc680d9..a7a4059 100644
--- a/netd/BpfHandler.cpp
+++ b/netd/BpfHandler.cpp
@@ -85,8 +85,24 @@
     // U bumps the kernel requirement up to 4.14
     if (modules::sdklevel::IsAtLeastU() && !bpf::isAtLeastKernelVersion(4, 14, 0)) abort();
 
-    // V bumps the kernel requirement up to 4.19
-    if (modules::sdklevel::IsAtLeastV() && !bpf::isAtLeastKernelVersion(4, 19, 0)) abort();
+    if (modules::sdklevel::IsAtLeastV()) {
+        // V bumps the kernel requirement up to 4.19
+        // see also: //system/netd/tests/kernel_test.cpp TestKernel419
+        if (!bpf::isAtLeastKernelVersion(4, 19, 0)) abort();
+
+        // Technically already required by U, but only enforce on V+
+        // see also: //system/netd/tests/kernel_test.cpp TestKernel64Bit
+        if (bpf::isKernel32Bit() && bpf::isAtLeastKernelVersion(5, 16, 0)) abort();
+    }
+
+    // Linux 6.1 is highest version supported by U, starting with V new kernels,
+    // ie. 6.2+ we are dropping various kernel/system userspace 32-on-64 hacks
+    // (for example "ANDROID: xfrm: remove in_compat_syscall() checks").
+    // Note: this check/enforcement only applies to *system* userspace code,
+    // it does not affect unprivileged apps, the 32-on-64 compatibility
+    // problems are AFAIK limited to various CAP_NET_ADMIN protected interfaces.
+    // see also: //system/bpf/bpfloader/BpfLoader.cpp main()
+    if (bpf::isUserspace32bit() && bpf::isAtLeastKernelVersion(6, 2, 0)) abort();
 
     // U mandates this mount point (though it should also be the case on T)
     if (modules::sdklevel::IsAtLeastU() && !!strcmp(cg2_path, "/sys/fs/cgroup")) abort();
@@ -113,6 +129,24 @@
         RETURN_IF_NOT_OK(
                 attachProgramToCgroup(CGROUP_SOCKET_PROG_PATH, cg_fd, BPF_CGROUP_INET_SOCK_CREATE));
     }
+
+    if (bpf::isAtLeastKernelVersion(4, 19, 0)) {
+        RETURN_IF_NOT_OK(attachProgramToCgroup(
+                "/sys/fs/bpf/netd_readonly/prog_block_bind4_block_port",
+                cg_fd, BPF_CGROUP_INET4_BIND));
+        RETURN_IF_NOT_OK(attachProgramToCgroup(
+                "/sys/fs/bpf/netd_readonly/prog_block_bind6_block_port",
+                cg_fd, BPF_CGROUP_INET6_BIND));
+
+        // This should trivially pass, since we just attached up above,
+        // but BPF_PROG_QUERY is only implemented on 4.19+ kernels.
+        if (bpf::queryProgram(cg_fd, BPF_CGROUP_INET_EGRESS) <= 0) abort();
+        if (bpf::queryProgram(cg_fd, BPF_CGROUP_INET_INGRESS) <= 0) abort();
+        if (bpf::queryProgram(cg_fd, BPF_CGROUP_INET_SOCK_CREATE) <= 0) abort();
+        if (bpf::queryProgram(cg_fd, BPF_CGROUP_INET4_BIND) <= 0) abort();
+        if (bpf::queryProgram(cg_fd, BPF_CGROUP_INET6_BIND) <= 0) abort();
+    }
+
     return netdutils::status::ok;
 }
 
@@ -214,7 +248,7 @@
     // which might toggle the live stats map and clean it.
     const auto countUidStatsEntries = [chargeUid, &totalEntryCount, &perUidEntryCount](
                                               const StatsKey& key,
-                                              const BpfMap<StatsKey, StatsValue>&) {
+                                              const BpfMapRO<StatsKey, StatsValue>&) {
         if (key.uid == chargeUid) {
             perUidEntryCount++;
         }
@@ -232,9 +266,8 @@
         return -EINVAL;
     }
 
-    BpfMap<StatsKey, StatsValue>& currentMap =
+    BpfMapRO<StatsKey, StatsValue>& currentMap =
             (configuration.value() == SELECT_MAP_A) ? mStatsMapA : mStatsMapB;
-    // HACK: mStatsMapB becomes RW BpfMap here, but countUidStatsEntries doesn't modify so it works
     base::Result<void> res = currentMap.iterate(countUidStatsEntries);
     if (!res.ok()) {
         ALOGE("Failed to count the stats entry in map: %s",
diff --git a/netd/BpfHandler.h b/netd/BpfHandler.h
index a6da4eb..9e69efc 100644
--- a/netd/BpfHandler.h
+++ b/netd/BpfHandler.h
@@ -59,10 +59,10 @@
     bool hasUpdateDeviceStatsPermission(uid_t uid);
 
     BpfMap<uint64_t, UidTagValue> mCookieTagMap;
-    BpfMap<StatsKey, StatsValue> mStatsMapA;
+    BpfMapRO<StatsKey, StatsValue> mStatsMapA;
     BpfMapRO<StatsKey, StatsValue> mStatsMapB;
     BpfMapRO<uint32_t, uint32_t> mConfigurationMap;
-    BpfMap<uint32_t, uint8_t> mUidPermissionMap;
+    BpfMapRO<uint32_t, uint8_t> mUidPermissionMap;
 
     // The limit on the number of stats entries a uid can have in the per uid stats map. BpfHandler
     // will block that specific uid from tagging new sockets after the limit is reached.
diff --git a/netd/BpfHandlerTest.cpp b/netd/BpfHandlerTest.cpp
index f5c9a68..b38fa16 100644
--- a/netd/BpfHandlerTest.cpp
+++ b/netd/BpfHandlerTest.cpp
@@ -49,7 +49,7 @@
     BpfHandler mBh;
     BpfMap<uint64_t, UidTagValue> mFakeCookieTagMap;
     BpfMap<StatsKey, StatsValue> mFakeStatsMapA;
-    BpfMapRO<uint32_t, uint32_t> mFakeConfigurationMap;
+    BpfMap<uint32_t, uint32_t> mFakeConfigurationMap;
     BpfMap<uint32_t, uint8_t> mFakeUidPermissionMap;
 
     void SetUp() {
diff --git a/remoteauth/service/Android.bp b/remoteauth/service/Android.bp
index 6e7b8d2..98ed2b2 100644
--- a/remoteauth/service/Android.bp
+++ b/remoteauth/service/Android.bp
@@ -18,7 +18,7 @@
 
 filegroup {
     name: "remoteauth-service-srcs",
-    srcs: ["java/**/*.java"],
+    srcs: [],
 }
 
 // Main lib for remoteauth services.
@@ -40,14 +40,10 @@
         "framework-statsd",
     ],
     static_libs: [
-        "guava",
-        "libprotobuf-java-lite",
-        "fast-pair-lite-protos",
         "modules-utils-build",
         "modules-utils-handlerexecutor",
         "modules-utils-preconditions",
         "modules-utils-backgroundthread",
-        "presence-lite-protos",
         "uwb_androidx_backend",
     ],
     sdk_version: "system_server_current",
diff --git a/remoteauth/service/java/com/android/server/remoteauth/ranging/RangingParameters.java b/remoteauth/service/java/com/android/server/remoteauth/ranging/RangingParameters.java
index 923730c..4b5874b 100644
--- a/remoteauth/service/java/com/android/server/remoteauth/ranging/RangingParameters.java
+++ b/remoteauth/service/java/com/android/server/remoteauth/ranging/RangingParameters.java
@@ -15,5 +15,67 @@
  */
 package com.android.server.remoteauth.ranging;
 
-/** The set of parameters to start ranging. */
-public class RangingParameters {}
+import androidx.core.uwb.backend.impl.internal.UwbAddress;
+
+/** The set of parameters to initiate {@link RangingSession#start}. */
+public class RangingParameters {
+
+    /** Parameters for {@link UwbRangingSession}. */
+    private final UwbAddress mUwbLocalAddress;
+
+    private final androidx.core.uwb.backend.impl.internal.RangingParameters mUwbRangingParameters;
+
+    public UwbAddress getUwbLocalAddress() {
+        return mUwbLocalAddress;
+    }
+
+    public androidx.core.uwb.backend.impl.internal.RangingParameters getUwbRangingParameters() {
+        return mUwbRangingParameters;
+    }
+
+    private RangingParameters(
+            UwbAddress uwbLocalAddress,
+            androidx.core.uwb.backend.impl.internal.RangingParameters uwbRangingParameters) {
+        mUwbLocalAddress = uwbLocalAddress;
+        mUwbRangingParameters = uwbRangingParameters;
+    }
+
+    /** Builder class for {@link RangingParameters}. */
+    public static final class Builder {
+        private UwbAddress mUwbLocalAddress;
+        private androidx.core.uwb.backend.impl.internal.RangingParameters mUwbRangingParameters;
+
+        /**
+         * Sets the uwb local address.
+         *
+         * <p>Only required if {@link SessionParameters#getRangingMethod}=={@link
+         * RANGING_METHOD_UWB} and {@link SessionParameters#getAutoDeriveParams} == false
+         */
+        public Builder setUwbLocalAddress(UwbAddress uwbLocalAddress) {
+            mUwbLocalAddress = uwbLocalAddress;
+            return this;
+        }
+
+        /**
+         * Sets the uwb ranging parameters.
+         *
+         * <p>Only required if {@link SessionParameters#getRangingMethod}=={@link
+         * RANGING_METHOD_UWB}.
+         *
+         * <p>If {@link SessionParameters#getAutoDeriveParams} == true, all required uwb parameters
+         * including uwbLocalAddress, complexChannel, peerAddresses, and sessionKeyInfo will be
+         * automatically derived, so unnecessary to provide and the other uwb parameters are
+         * optional.
+         */
+        public Builder setUwbRangingParameters(
+                androidx.core.uwb.backend.impl.internal.RangingParameters uwbRangingParameters) {
+            mUwbRangingParameters = uwbRangingParameters;
+            return this;
+        }
+
+        /** Builds {@link RangingParameters}. */
+        public RangingParameters build() {
+            return new RangingParameters(mUwbLocalAddress, mUwbRangingParameters);
+        }
+    }
+}
diff --git a/remoteauth/service/java/com/android/server/remoteauth/ranging/RangingSession.java b/remoteauth/service/java/com/android/server/remoteauth/ranging/RangingSession.java
index adb36c5..a922168 100644
--- a/remoteauth/service/java/com/android/server/remoteauth/ranging/RangingSession.java
+++ b/remoteauth/service/java/com/android/server/remoteauth/ranging/RangingSession.java
@@ -37,7 +37,8 @@
  * <p>A session can be started and stopped multiple times. After starting, updates ({@link
  * RangingReport}, {@link RangingError}, etc) will be reported via the provided {@link
  * RangingCallback}. BaseKey and SyncData are used for auto derivation of supported ranging
- * parameters, which will be implementation specific.
+ * parameters, which will be implementation specific. All session creation shall only be conducted
+ * via {@link RangingManager#createSession}.
  *
  * <p>Ranging method specific implementation shall be implemented in the extended class.
  */
diff --git a/remoteauth/service/java/com/android/server/remoteauth/ranging/UwbRangingSession.java b/remoteauth/service/java/com/android/server/remoteauth/ranging/UwbRangingSession.java
index 2015b66..62463e1 100644
--- a/remoteauth/service/java/com/android/server/remoteauth/ranging/UwbRangingSession.java
+++ b/remoteauth/service/java/com/android/server/remoteauth/ranging/UwbRangingSession.java
@@ -15,30 +15,219 @@
  */
 package com.android.server.remoteauth.ranging;
 
-import android.annotation.NonNull;
-import android.content.Context;
+import static androidx.core.uwb.backend.impl.internal.RangingDevice.SESSION_ID_UNSET;
+import static androidx.core.uwb.backend.impl.internal.Utils.STATUS_OK;
+import static androidx.core.uwb.backend.impl.internal.Utils.SUPPORTED_BPRF_PREAMBLE_INDEX;
+import static androidx.core.uwb.backend.impl.internal.UwbAddress.SHORT_ADDRESS_LENGTH;
 
+import static com.android.server.remoteauth.ranging.RangingReport.PROXIMITY_STATE_INSIDE;
+import static com.android.server.remoteauth.ranging.RangingReport.PROXIMITY_STATE_OUTSIDE;
+import static com.android.server.remoteauth.ranging.SessionParameters.DEVICE_ROLE_INITIATOR;
+
+import static com.google.uwb.support.fira.FiraParams.UWB_CHANNEL_9;
+
+import android.annotation.NonNull;
+import android.annotation.RequiresApi;
+import android.content.Context;
+import android.os.Build;
+import android.util.Log;
+
+import androidx.core.uwb.backend.impl.internal.RangingController;
+import androidx.core.uwb.backend.impl.internal.RangingDevice;
+import androidx.core.uwb.backend.impl.internal.RangingPosition;
+import androidx.core.uwb.backend.impl.internal.RangingSessionCallback;
+import androidx.core.uwb.backend.impl.internal.RangingSessionCallback.RangingSuspendedReason;
+import androidx.core.uwb.backend.impl.internal.UwbAddress;
+import androidx.core.uwb.backend.impl.internal.UwbComplexChannel;
+import androidx.core.uwb.backend.impl.internal.UwbDevice;
 import androidx.core.uwb.backend.impl.internal.UwbServiceImpl;
 
-import java.util.concurrent.Executor;
+import com.android.internal.util.Preconditions;
 
-/** UWB (ultra wide-band) implementation of {@link RangingSession}. */
+import java.nio.ByteBuffer;
+import java.util.List;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+
+/** UWB (ultra wide-band) specific implementation of {@link RangingSession}. */
+@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
 public class UwbRangingSession extends RangingSession {
-    private static final int DERIVED_DATA_LENGTH = 1;
+    private static final String TAG = "UwbRangingSession";
+
+    private static final int COMPLEX_CHANNEL_LENGTH = 1;
+    private static final int STS_KEY_LENGTH = 16;
+    private static final int DERIVED_DATA_LENGTH =
+            COMPLEX_CHANNEL_LENGTH + SHORT_ADDRESS_LENGTH + SHORT_ADDRESS_LENGTH + STS_KEY_LENGTH;
+
+    private final UwbServiceImpl mUwbServiceImpl;
+    private final RangingDevice mRangingDevice;
+
+    private Executor mExecutor;
+    private RangingCallback mRangingCallback;
 
     public UwbRangingSession(
             @NonNull Context context,
             @NonNull SessionParameters sessionParameters,
             @NonNull UwbServiceImpl uwbServiceImpl) {
         super(context, sessionParameters, DERIVED_DATA_LENGTH);
+        Preconditions.checkNotNull(uwbServiceImpl);
+        mUwbServiceImpl = uwbServiceImpl;
+        if (sessionParameters.getDeviceRole() == DEVICE_ROLE_INITIATOR) {
+            mRangingDevice = (RangingDevice) mUwbServiceImpl.getController(context);
+        } else {
+            mRangingDevice = (RangingDevice) mUwbServiceImpl.getControlee(context);
+        }
     }
 
     @Override
     public void start(
             @NonNull RangingParameters rangingParameters,
             @NonNull Executor executor,
-            @NonNull RangingCallback rangingCallback) {}
+            @NonNull RangingCallback rangingCallback) {
+        Preconditions.checkNotNull(rangingParameters, "rangingParameters must not be null");
+        Preconditions.checkNotNull(executor, "executor must not be null");
+        Preconditions.checkNotNull(rangingCallback, "rangingCallback must not be null");
+
+        setUwbRangingParameters(rangingParameters);
+        int status =
+                mRangingDevice.startRanging(
+                        convertCallback(rangingCallback, executor),
+                        Executors.newSingleThreadExecutor());
+        if (status != STATUS_OK) {
+            Log.w(TAG, String.format("Uwb ranging start failed with status %d", status));
+            executor.execute(
+                    () -> rangingCallback.onError(mSessionInfo, RANGING_ERROR_FAILED_TO_START));
+            return;
+        }
+        mExecutor = executor;
+        mRangingCallback = rangingCallback;
+        Log.i(TAG, "start");
+    }
 
     @Override
-    public void stop() {}
+    public void stop() {
+        if (mRangingCallback == null) {
+            Log.w(TAG, String.format("Failed to stop unstarted session"));
+            return;
+        }
+        int status = mRangingDevice.stopRanging();
+        if (status != STATUS_OK) {
+            Log.w(TAG, String.format("Uwb ranging stop failed with status %d", status));
+            mExecutor.execute(
+                    () -> mRangingCallback.onError(mSessionInfo, RANGING_ERROR_FAILED_TO_STOP));
+            return;
+        }
+        mRangingCallback = null;
+        Log.i(TAG, "stop");
+    }
+
+    private void setUwbRangingParameters(RangingParameters rangingParameters) {
+        androidx.core.uwb.backend.impl.internal.RangingParameters params =
+                rangingParameters.getUwbRangingParameters();
+        Preconditions.checkNotNull(params, "uwbRangingParameters must not be null");
+        if (mAutoDeriveParams) {
+            Preconditions.checkArgument(mDerivedData.length == DERIVED_DATA_LENGTH);
+            ByteBuffer buffer = ByteBuffer.wrap(mDerivedData);
+
+            byte complexChannelByte = buffer.get();
+            int preambleIndex =
+                    SUPPORTED_BPRF_PREAMBLE_INDEX.get(
+                            Math.abs(complexChannelByte) % SUPPORTED_BPRF_PREAMBLE_INDEX.size());
+            // Selecting channel 9 since it's the only mandatory channel.
+            UwbComplexChannel complexChannel = new UwbComplexChannel(UWB_CHANNEL_9, preambleIndex);
+
+            byte[] localAddress = new byte[SHORT_ADDRESS_LENGTH];
+            byte[] peerAddress = new byte[SHORT_ADDRESS_LENGTH];
+            if (mRangingDevice instanceof RangingController) {
+                ((RangingController) mRangingDevice).setComplexChannel(complexChannel);
+                buffer.get(localAddress);
+                buffer.get(peerAddress);
+            } else {
+                buffer.get(peerAddress);
+                buffer.get(localAddress);
+            }
+            byte[] stsKey = new byte[STS_KEY_LENGTH];
+            buffer.get(stsKey);
+
+            mRangingDevice.setLocalAddress(UwbAddress.fromBytes(localAddress));
+            mRangingDevice.setRangingParameters(
+                    new androidx.core.uwb.backend.impl.internal.RangingParameters(
+                            params.getUwbConfigId(),
+                            SESSION_ID_UNSET,
+                            /* subSessionId= */ SESSION_ID_UNSET,
+                            stsKey,
+                            /* subSessionInfo= */ new byte[] {},
+                            complexChannel,
+                            List.of(UwbAddress.fromBytes(peerAddress)),
+                            params.getRangingUpdateRate(),
+                            params.getUwbRangeDataNtfConfig(),
+                            params.getSlotDuration(),
+                            params.isAoaDisabled()));
+        } else {
+            UwbAddress localAddress = rangingParameters.getUwbLocalAddress();
+            Preconditions.checkNotNull(localAddress, "localAddress must not be null");
+            UwbComplexChannel complexChannel = params.getComplexChannel();
+            Preconditions.checkNotNull(complexChannel, "complexChannel must not be null");
+            mRangingDevice.setLocalAddress(localAddress);
+            if (mRangingDevice instanceof RangingController) {
+                ((RangingController) mRangingDevice).setComplexChannel(complexChannel);
+            }
+            mRangingDevice.setRangingParameters(params);
+        }
+    }
+
+    private RangingSessionCallback convertCallback(RangingCallback callback, Executor executor) {
+        return new RangingSessionCallback() {
+
+            @Override
+            public void onRangingInitialized(UwbDevice device) {
+                Log.i(TAG, "onRangingInitialized");
+            }
+
+            @Override
+            public void onRangingResult(UwbDevice device, RangingPosition position) {
+                float distanceM = position.getDistance().getValue();
+                int proximityState =
+                        (mLowerProximityBoundaryM <= distanceM
+                                        && distanceM <= mUpperProximityBoundaryM)
+                                ? PROXIMITY_STATE_INSIDE
+                                : PROXIMITY_STATE_OUTSIDE;
+                position.getDistance().getValue();
+                RangingReport rangingReport =
+                        new RangingReport.Builder()
+                                .setDistanceM(distanceM)
+                                .setProximityState(proximityState)
+                                .build();
+                executor.execute(() -> callback.onRangingReport(mSessionInfo, rangingReport));
+            }
+
+            @Override
+            public void onRangingSuspended(UwbDevice device, @RangingSuspendedReason int reason) {
+                executor.execute(() -> callback.onError(mSessionInfo, convertError(reason)));
+            }
+        };
+    }
+
+    @RangingError
+    private static int convertError(@RangingSuspendedReason int reason) {
+        if (reason == RangingSessionCallback.REASON_WRONG_PARAMETERS) {
+            return RANGING_ERROR_INVALID_PARAMETERS;
+        }
+        if (reason == RangingSessionCallback.REASON_STOP_RANGING_CALLED) {
+            return RANGING_ERROR_STOPPED_BY_REQUEST;
+        }
+        if (reason == RangingSessionCallback.REASON_STOPPED_BY_PEER) {
+            return RANGING_ERROR_STOPPED_BY_PEER;
+        }
+        if (reason == RangingSessionCallback.REASON_FAILED_TO_START) {
+            return RANGING_ERROR_FAILED_TO_START;
+        }
+        if (reason == RangingSessionCallback.REASON_SYSTEM_POLICY) {
+            return RANGING_ERROR_SYSTEM_ERROR;
+        }
+        if (reason == RangingSessionCallback.REASON_MAX_RANGING_ROUND_RETRY_REACHED) {
+            return RANGING_ERROR_SYSTEM_TIMEOUT;
+        }
+        return RANGING_ERROR_UNKNOWN;
+    }
 }
diff --git a/remoteauth/tests/unit/Android.bp b/remoteauth/tests/unit/Android.bp
index 37c78c7..a21c033 100644
--- a/remoteauth/tests/unit/Android.bp
+++ b/remoteauth/tests/unit/Android.bp
@@ -20,13 +20,13 @@
     name: "RemoteAuthUnitTests",
     defaults: [
         "enable-remoteauth-targets",
-        "mts-target-sdk-version-current"
+        "mts-target-sdk-version-current",
     ],
     sdk_version: "test_current",
     min_sdk_version: "31",
 
     // Include all test java files.
-    srcs: ["src/**/*.java"],
+    srcs: [],
 
     libs: [
         "android.test.base",
@@ -45,7 +45,7 @@
         "mockito-target-extended-minus-junit4",
         "platform-test-annotations",
         "service-remoteauth-pre-jarjar",
-        "truth-prebuilt",
+        "truth",
     ],
     // these are needed for Extended Mockito
     jni_libs: [
diff --git a/remoteauth/tests/unit/src/com/android/server/remoteauth/ranging/RangingParametersTest.java b/remoteauth/tests/unit/src/com/android/server/remoteauth/ranging/RangingParametersTest.java
new file mode 100644
index 0000000..3be5e70
--- /dev/null
+++ b/remoteauth/tests/unit/src/com/android/server/remoteauth/ranging/RangingParametersTest.java
@@ -0,0 +1,69 @@
+/*
+ * 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.remoteauth.ranging;
+
+import static androidx.core.uwb.backend.impl.internal.RangingDevice.SESSION_ID_UNSET;
+import static androidx.core.uwb.backend.impl.internal.Utils.CONFIG_PROVISIONED_UNICAST_DS_TWR;
+import static androidx.core.uwb.backend.impl.internal.Utils.DURATION_1_MS;
+import static androidx.core.uwb.backend.impl.internal.Utils.NORMAL;
+
+import static com.google.uwb.support.fira.FiraParams.UWB_CHANNEL_9;
+
+import static org.junit.Assert.assertEquals;
+
+import androidx.core.uwb.backend.impl.internal.UwbAddress;
+import androidx.core.uwb.backend.impl.internal.UwbComplexChannel;
+import androidx.core.uwb.backend.impl.internal.UwbRangeDataNtfConfig;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.List;
+
+/** Unit test for {@link RangingParameters}. */
+@RunWith(AndroidJUnit4.class)
+public class RangingParametersTest {
+
+    private static final UwbAddress TEST_UWB_LOCAL_ADDRESS =
+            UwbAddress.fromBytes(new byte[] {0x00, 0x01});
+    private static final androidx.core.uwb.backend.impl.internal.RangingParameters
+            TEST_UWB_RANGING_PARAMETERS =
+                    new androidx.core.uwb.backend.impl.internal.RangingParameters(
+                            CONFIG_PROVISIONED_UNICAST_DS_TWR,
+                            /* sessionId= */ SESSION_ID_UNSET,
+                            /* subSessionId= */ SESSION_ID_UNSET,
+                            /* SessionInfo= */ new byte[] {},
+                            /* subSessionInfo= */ new byte[] {},
+                            new UwbComplexChannel(UWB_CHANNEL_9, /* preambleIndex= */ 9),
+                            List.of(UwbAddress.fromBytes(new byte[] {0x00, 0x02})),
+                            /* rangingUpdateRate= */ NORMAL,
+                            new UwbRangeDataNtfConfig.Builder().build(),
+                            /* slotDuration= */ DURATION_1_MS,
+                            /* isAoaDisabled= */ false);
+
+    @Test
+    public void testBuildingRangingParameters_success() {
+        final RangingParameters rangingParameters =
+                new RangingParameters.Builder()
+                        .setUwbLocalAddress(TEST_UWB_LOCAL_ADDRESS)
+                        .setUwbRangingParameters(TEST_UWB_RANGING_PARAMETERS)
+                        .build();
+
+        assertEquals(rangingParameters.getUwbLocalAddress(), TEST_UWB_LOCAL_ADDRESS);
+        assertEquals(rangingParameters.getUwbRangingParameters(), TEST_UWB_RANGING_PARAMETERS);
+    }
+}
diff --git a/remoteauth/tests/unit/src/com/android/server/remoteauth/ranging/UwbRangingSessionTest.java b/remoteauth/tests/unit/src/com/android/server/remoteauth/ranging/UwbRangingSessionTest.java
new file mode 100644
index 0000000..91198ab
--- /dev/null
+++ b/remoteauth/tests/unit/src/com/android/server/remoteauth/ranging/UwbRangingSessionTest.java
@@ -0,0 +1,375 @@
+/*
+ * 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.remoteauth.ranging;
+
+import static androidx.core.uwb.backend.impl.internal.RangingDevice.SESSION_ID_UNSET;
+import static androidx.core.uwb.backend.impl.internal.RangingMeasurement.CONFIDENCE_HIGH;
+import static androidx.core.uwb.backend.impl.internal.Utils.CONFIG_PROVISIONED_UNICAST_DS_TWR;
+import static androidx.core.uwb.backend.impl.internal.Utils.DURATION_1_MS;
+import static androidx.core.uwb.backend.impl.internal.Utils.NORMAL;
+import static androidx.core.uwb.backend.impl.internal.Utils.STATUS_ERROR;
+import static androidx.core.uwb.backend.impl.internal.Utils.STATUS_OK;
+
+import static com.android.server.remoteauth.ranging.RangingCapabilities.RANGING_METHOD_UWB;
+import static com.android.server.remoteauth.ranging.RangingReport.PROXIMITY_STATE_INSIDE;
+import static com.android.server.remoteauth.ranging.RangingSession.RANGING_ERROR_FAILED_TO_START;
+import static com.android.server.remoteauth.ranging.RangingSession.RANGING_ERROR_FAILED_TO_STOP;
+import static com.android.server.remoteauth.ranging.SessionParameters.DEVICE_ROLE_INITIATOR;
+import static com.android.server.remoteauth.ranging.SessionParameters.DEVICE_ROLE_RESPONDER;
+
+import static com.google.uwb.support.fira.FiraParams.UWB_CHANNEL_9;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyZeroInteractions;
+import static org.mockito.Mockito.when;
+
+import android.content.Context;
+
+import androidx.core.uwb.backend.impl.internal.RangingControlee;
+import androidx.core.uwb.backend.impl.internal.RangingController;
+import androidx.core.uwb.backend.impl.internal.RangingMeasurement;
+import androidx.core.uwb.backend.impl.internal.RangingPosition;
+import androidx.core.uwb.backend.impl.internal.RangingSessionCallback;
+import androidx.core.uwb.backend.impl.internal.UwbAddress;
+import androidx.core.uwb.backend.impl.internal.UwbComplexChannel;
+import androidx.core.uwb.backend.impl.internal.UwbDevice;
+import androidx.core.uwb.backend.impl.internal.UwbRangeDataNtfConfig;
+import androidx.core.uwb.backend.impl.internal.UwbServiceImpl;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import com.android.server.remoteauth.ranging.RangingCapabilities.RangingMethod;
+import com.android.server.remoteauth.ranging.RangingSession.RangingCallback;
+
+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 org.mockito.invocation.InvocationOnMock;
+import org.mockito.stubbing.Answer;
+
+import java.util.List;
+import java.util.concurrent.Executor;
+
+/** Unit test for {@link UwbRangingSession}. */
+@RunWith(AndroidJUnit4.class)
+public class UwbRangingSessionTest {
+
+    private static final String TEST_DEVICE_ID = "test_device_id";
+    @RangingMethod private static final int TEST_RANGING_METHOD = RANGING_METHOD_UWB;
+    private static final float TEST_LOWER_PROXIMITY_BOUNDARY_M = 1.0f;
+    private static final float TEST_UPPER_PROXIMITY_BOUNDARY_M = 2.5f;
+    private static final boolean TEST_AUTO_DERIVE_PARAMS = true;
+    private static final byte[] TEST_BASE_KEY =
+            new byte[] {
+                0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
+                0x0e, 0x0f
+            };
+    private static final byte[] TEST_SYNC_DATA =
+            new byte[] {
+                0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
+                0x0f, 0x00
+            };
+    private static final SessionParameters TEST_SESSION_PARAMETER_INITIATOR =
+            new SessionParameters.Builder()
+                    .setDeviceId(TEST_DEVICE_ID)
+                    .setRangingMethod(TEST_RANGING_METHOD)
+                    .setDeviceRole(DEVICE_ROLE_INITIATOR)
+                    .setLowerProximityBoundaryM(TEST_LOWER_PROXIMITY_BOUNDARY_M)
+                    .setUpperProximityBoundaryM(TEST_UPPER_PROXIMITY_BOUNDARY_M)
+                    .build();
+    private static final SessionParameters TEST_SESSION_PARAMETER_RESPONDER =
+            new SessionParameters.Builder()
+                    .setDeviceId(TEST_DEVICE_ID)
+                    .setRangingMethod(TEST_RANGING_METHOD)
+                    .setDeviceRole(DEVICE_ROLE_RESPONDER)
+                    .setLowerProximityBoundaryM(TEST_LOWER_PROXIMITY_BOUNDARY_M)
+                    .setUpperProximityBoundaryM(TEST_UPPER_PROXIMITY_BOUNDARY_M)
+                    .build();
+    private static final SessionParameters TEST_SESSION_PARAMETER_INITIATOR_W_AD =
+            new SessionParameters.Builder()
+                    .setDeviceId(TEST_DEVICE_ID)
+                    .setRangingMethod(TEST_RANGING_METHOD)
+                    .setDeviceRole(DEVICE_ROLE_INITIATOR)
+                    .setLowerProximityBoundaryM(TEST_LOWER_PROXIMITY_BOUNDARY_M)
+                    .setUpperProximityBoundaryM(TEST_UPPER_PROXIMITY_BOUNDARY_M)
+                    .setAutoDeriveParams(TEST_AUTO_DERIVE_PARAMS)
+                    .setBaseKey(TEST_BASE_KEY)
+                    .setSyncData(TEST_SYNC_DATA)
+                    .build();
+    private static final UwbAddress TEST_UWB_LOCAL_ADDRESS =
+            UwbAddress.fromBytes(new byte[] {0x00, 0x01});
+    private static final UwbAddress TEST_UWB_PEER_ADDRESS =
+            UwbAddress.fromBytes(new byte[] {0x00, 0x02});
+    private static final UwbComplexChannel TEST_UWB_COMPLEX_CHANNEL =
+            new UwbComplexChannel(UWB_CHANNEL_9, /* preambleIndex= */ 9);
+    private static final androidx.core.uwb.backend.impl.internal.RangingParameters
+            TEST_UWB_RANGING_PARAMETERS =
+                    new androidx.core.uwb.backend.impl.internal.RangingParameters(
+                            CONFIG_PROVISIONED_UNICAST_DS_TWR,
+                            /* sessionId= */ SESSION_ID_UNSET,
+                            /* subSessionId= */ SESSION_ID_UNSET,
+                            /* SessionInfo= */ new byte[] {},
+                            /* subSessionInfo= */ new byte[] {},
+                            TEST_UWB_COMPLEX_CHANNEL,
+                            List.of(TEST_UWB_PEER_ADDRESS),
+                            NORMAL,
+                            new UwbRangeDataNtfConfig.Builder().build(),
+                            DURATION_1_MS,
+                            /* isAoaDisabled= */ false);
+    private static final RangingParameters TEST_RANGING_PARAMETERS =
+            new RangingParameters.Builder()
+                    .setUwbLocalAddress(TEST_UWB_LOCAL_ADDRESS)
+                    .setUwbRangingParameters(TEST_UWB_RANGING_PARAMETERS)
+                    .build();
+    private static final UwbAddress TEST_DERIVED_UWB_LOCAL_ADDRESS =
+            UwbAddress.fromBytes(new byte[] {0x4C, (byte) 0xB4});
+    private static final UwbAddress TEST_DERIVED_UWB_PEER_ADDRESS =
+            UwbAddress.fromBytes(new byte[] {(byte) 0xAE, 0x2E});
+    private static final UwbComplexChannel TEST_DERIVED_UWB_COMPLEX_CHANNEL =
+            new UwbComplexChannel(UWB_CHANNEL_9, /* preambleIndex= */ 12);
+    private static final byte[] TEST_DERIVED_STS_KEY =
+            new byte[] {
+                0x76,
+                (byte) 0xD7,
+                (byte) 0xB6,
+                0x1A,
+                (byte) 0x8D,
+                0x29,
+                0x1A,
+                0x52,
+                (byte) 0xBB,
+                (byte) 0xBF,
+                (byte) 0xE6,
+                0x28,
+                (byte) 0xAD,
+                0x44,
+                (byte) 0xFB,
+                0x2E
+            };
+
+    private static final UwbDevice TEST_UWB_DEVICE =
+            UwbDevice.createForAddress(TEST_UWB_PEER_ADDRESS.toBytes());
+    private static final float TEST_DISTANCE = 1.5f;
+    private static final RangingMeasurement TEST_RANGING_MEASUREMENT =
+            new RangingMeasurement(
+                    /* confidence= */ CONFIDENCE_HIGH,
+                    /* value= */ TEST_DISTANCE,
+                    /* valid= */ true);
+    private static final RangingPosition TEST_RANGING_POSITION =
+            new RangingPosition(
+                    TEST_RANGING_MEASUREMENT,
+                    /* azimuth= */ null,
+                    /* elevation= */ null,
+                    /* dlTdoaMeasurement= */ null,
+                    /* elapsedRealtimeNanos= */ 0,
+                    /* rssi= */ 0);
+
+    @Mock private Context mContext;
+    @Mock private UwbServiceImpl mUwbServiceImpl;
+    @Mock private RangingController mRangingController;
+    @Mock private RangingControlee mRangingControlee;
+    @Mock private RangingCallback mRangingCallback;
+    @Mock private Executor mCallbackExecutor;
+
+    private UwbRangingSession mUwbRangingSession;
+
+    @Before
+    public void setUp() throws Exception {
+        MockitoAnnotations.initMocks(this);
+
+        when(mUwbServiceImpl.getController(mContext)).thenReturn(mRangingController);
+        when(mUwbServiceImpl.getControlee(mContext)).thenReturn(mRangingControlee);
+        when(mRangingController.startRanging(any(), any())).thenReturn(STATUS_OK);
+        when(mRangingControlee.startRanging(any(), any())).thenReturn(STATUS_OK);
+        doAnswer(
+                invocation -> {
+                    Runnable t = invocation.getArgument(0);
+                    t.run();
+                    return true;
+                })
+                .when(mCallbackExecutor)
+                .execute(any(Runnable.class));
+    }
+
+    @Test
+    public void testConstruction_nullArgument() {
+        assertThrows(
+                NullPointerException.class,
+                () ->
+                        new UwbRangingSession(
+                                null, TEST_SESSION_PARAMETER_INITIATOR, mUwbServiceImpl));
+        assertThrows(
+                NullPointerException.class,
+                () -> new UwbRangingSession(mContext, null, mUwbServiceImpl));
+        assertThrows(
+                NullPointerException.class,
+                () -> new UwbRangingSession(mContext, TEST_SESSION_PARAMETER_INITIATOR, null));
+    }
+
+    @Test
+    public void testConstruction_initiatorSuccess() {
+        mUwbRangingSession =
+                new UwbRangingSession(mContext, TEST_SESSION_PARAMETER_INITIATOR, mUwbServiceImpl);
+        verify(mUwbServiceImpl, times(1)).getController(mContext);
+    }
+
+    @Test
+    public void testConstruction_responderSuccess() {
+        mUwbRangingSession =
+                new UwbRangingSession(mContext, TEST_SESSION_PARAMETER_RESPONDER, mUwbServiceImpl);
+        verify(mUwbServiceImpl, times(1)).getControlee(mContext);
+    }
+
+    @Test
+    public void testStart_nullArgument() {
+        mUwbRangingSession =
+                new UwbRangingSession(mContext, TEST_SESSION_PARAMETER_INITIATOR, mUwbServiceImpl);
+
+        assertThrows(
+                NullPointerException.class,
+                () -> mUwbRangingSession.start(TEST_RANGING_PARAMETERS, mCallbackExecutor, null));
+        assertThrows(
+                NullPointerException.class,
+                () -> mUwbRangingSession.start(null, mCallbackExecutor, mRangingCallback));
+        assertThrows(
+                NullPointerException.class,
+                () -> mUwbRangingSession.start(TEST_RANGING_PARAMETERS, null, mRangingCallback));
+        assertThrows(
+                NullPointerException.class,
+                () ->
+                        mUwbRangingSession.start(
+                                new RangingParameters.Builder().build(),
+                                mCallbackExecutor,
+                                mRangingCallback));
+    }
+
+    @Test
+    public void testStart_initiatorWithoutADFailed() {
+        when(mRangingController.startRanging(any(), any())).thenReturn(STATUS_ERROR);
+
+        mUwbRangingSession =
+                new UwbRangingSession(mContext, TEST_SESSION_PARAMETER_INITIATOR, mUwbServiceImpl);
+        mUwbRangingSession.start(TEST_RANGING_PARAMETERS, mCallbackExecutor, mRangingCallback);
+
+        verify(mRangingController, times(1)).setComplexChannel(TEST_UWB_COMPLEX_CHANNEL);
+        verify(mRangingController, times(1)).setLocalAddress(TEST_UWB_LOCAL_ADDRESS);
+        verify(mRangingController, times(1)).setRangingParameters(TEST_UWB_RANGING_PARAMETERS);
+        verify(mRangingController, times(1)).startRanging(any(), any());
+        ArgumentCaptor<SessionInfo> captor = ArgumentCaptor.forClass(SessionInfo.class);
+        verify(mRangingCallback, times(1))
+                .onError(captor.capture(), eq(RANGING_ERROR_FAILED_TO_START));
+        assertEquals(captor.getValue().getDeviceId(), TEST_DEVICE_ID);
+    }
+
+    private void testRangingCallback() {
+        Answer startRangingResponse =
+                new Answer() {
+                    public Object answer(InvocationOnMock invocation) {
+                        Object[] args = invocation.getArguments();
+                        RangingSessionCallback cb = (RangingSessionCallback) args[0];
+                        cb.onRangingInitialized(TEST_UWB_DEVICE);
+                        cb.onRangingResult(TEST_UWB_DEVICE, TEST_RANGING_POSITION);
+                        return STATUS_OK;
+                    }
+                };
+        doAnswer(startRangingResponse)
+                .when(mRangingController)
+                .startRanging(any(RangingSessionCallback.class), any());
+    }
+
+    @Test
+    public void testStart_initiatorWithADSucceed() {
+        testRangingCallback();
+        mUwbRangingSession =
+                new UwbRangingSession(
+                        mContext, TEST_SESSION_PARAMETER_INITIATOR_W_AD, mUwbServiceImpl);
+        mUwbRangingSession.start(TEST_RANGING_PARAMETERS, mCallbackExecutor, mRangingCallback);
+
+        verify(mRangingController, times(1)).setComplexChannel(TEST_DERIVED_UWB_COMPLEX_CHANNEL);
+        verify(mRangingController, times(1)).setLocalAddress(TEST_DERIVED_UWB_LOCAL_ADDRESS);
+        ArgumentCaptor<androidx.core.uwb.backend.impl.internal.RangingParameters> captor =
+                ArgumentCaptor.forClass(
+                        androidx.core.uwb.backend.impl.internal.RangingParameters.class);
+        verify(mRangingController, times(1)).setRangingParameters(captor.capture());
+        assertEquals(
+                captor.getValue().getUwbConfigId(), TEST_UWB_RANGING_PARAMETERS.getUwbConfigId());
+        assertEquals(captor.getValue().getSessionId(), SESSION_ID_UNSET);
+        assertEquals(captor.getValue().getSubSessionId(), SESSION_ID_UNSET);
+        assertArrayEquals(captor.getValue().getSessionKeyInfo(), TEST_DERIVED_STS_KEY);
+        assertArrayEquals(captor.getValue().getSubSessionKeyInfo(), new byte[] {});
+        assertEquals(captor.getValue().getComplexChannel(), TEST_DERIVED_UWB_COMPLEX_CHANNEL);
+        assertEquals(captor.getValue().getPeerAddresses().get(0), TEST_DERIVED_UWB_PEER_ADDRESS);
+        assertEquals(
+                captor.getValue().getRangingUpdateRate(),
+                TEST_UWB_RANGING_PARAMETERS.getRangingUpdateRate());
+        assertEquals(
+                captor.getValue().getUwbRangeDataNtfConfig(),
+                TEST_UWB_RANGING_PARAMETERS.getUwbRangeDataNtfConfig());
+        assertEquals(
+                captor.getValue().getSlotDuration(), TEST_UWB_RANGING_PARAMETERS.getSlotDuration());
+        assertEquals(
+                captor.getValue().isAoaDisabled(), TEST_UWB_RANGING_PARAMETERS.isAoaDisabled());
+        verify(mRangingController, times(1)).startRanging(any(), any());
+        ArgumentCaptor<SessionInfo> captor2 = ArgumentCaptor.forClass(SessionInfo.class);
+        ArgumentCaptor<RangingReport> captor3 = ArgumentCaptor.forClass(RangingReport.class);
+        verify(mRangingCallback, times(1)).onRangingReport(captor2.capture(), captor3.capture());
+        assertEquals(captor2.getValue().getDeviceId(), TEST_DEVICE_ID);
+        RangingReport rangingReport = captor3.getValue();
+        assertEquals(rangingReport.getDistanceM(), TEST_DISTANCE, 0.0f);
+        assertEquals(rangingReport.getProximityState(), PROXIMITY_STATE_INSIDE);
+    }
+
+    @Test
+    public void testStop_sessionNotStarted() {
+        when(mRangingController.stopRanging()).thenReturn(STATUS_ERROR);
+
+        mUwbRangingSession =
+                new UwbRangingSession(mContext, TEST_SESSION_PARAMETER_INITIATOR, mUwbServiceImpl);
+        mUwbRangingSession.stop();
+
+        verifyZeroInteractions(mRangingController);
+        verifyZeroInteractions(mRangingCallback);
+    }
+
+    @Test
+    public void testStop_failed() {
+        when(mRangingController.stopRanging()).thenReturn(STATUS_ERROR);
+
+        mUwbRangingSession =
+                new UwbRangingSession(mContext, TEST_SESSION_PARAMETER_INITIATOR, mUwbServiceImpl);
+        mUwbRangingSession.start(TEST_RANGING_PARAMETERS, mCallbackExecutor, mRangingCallback);
+        mUwbRangingSession.stop();
+
+        verify(mRangingController, times(1)).setComplexChannel(any());
+        verify(mRangingController, times(1)).setLocalAddress(any());
+        verify(mRangingController, times(1)).setRangingParameters(any());
+        verify(mRangingController, times(1)).startRanging(any(), any());
+        verify(mRangingController, times(1)).stopRanging();
+        ArgumentCaptor<SessionInfo> captor = ArgumentCaptor.forClass(SessionInfo.class);
+        verify(mRangingCallback, times(1))
+                .onError(captor.capture(), eq(RANGING_ERROR_FAILED_TO_STOP));
+        assertEquals(captor.getValue().getDeviceId(), TEST_DEVICE_ID);
+    }
+}
diff --git a/service-t/Android.bp b/service-t/Android.bp
index 7e2d2f4..bc49f0e 100644
--- a/service-t/Android.bp
+++ b/service-t/Android.bp
@@ -102,7 +102,12 @@
     ],
     exclude_srcs: [
         "src/com/android/server/connectivity/mdns/internal/SocketNetlinkMonitor.java",
-        "src/com/android/server/connectivity/mdns/SocketNetLinkMonitorFactory.java"
+        "src/com/android/server/connectivity/mdns/SocketNetLinkMonitorFactory.java",
+        "src/com/android/server/connectivity/mdns/MdnsAdvertiser.java",
+        "src/com/android/server/connectivity/mdns/MdnsAnnouncer.java",
+        "src/com/android/server/connectivity/mdns/MdnsInterfaceAdvertiser.java",
+        "src/com/android/server/connectivity/mdns/MdnsProber.java",
+        "src/com/android/server/connectivity/mdns/MdnsRecordRepository.java",
     ],
     static_libs: [
         "net-utils-device-common-mdns-standalone-build-test",
diff --git a/service-t/Sources.bp b/service-t/Sources.bp
index 187eadf..fbe02a5 100644
--- a/service-t/Sources.bp
+++ b/service-t/Sources.bp
@@ -20,7 +20,6 @@
     srcs: [
         "jni/com_android_server_net_NetworkStatsFactory.cpp",
     ],
-    path: "jni",
     visibility: [
         "//packages/modules/Connectivity:__subpackages__",
     ],
@@ -32,7 +31,6 @@
         "jni/com_android_server_net_NetworkStatsFactory.cpp",
         "jni/com_android_server_net_NetworkStatsService.cpp",
     ],
-    path: "jni",
     visibility: [
         "//packages/modules/Connectivity:__subpackages__",
     ],
diff --git a/service-t/lint-baseline.xml b/service-t/lint-baseline.xml
new file mode 100644
index 0000000..38d3ab0
--- /dev/null
+++ b/service-t/lint-baseline.xml
@@ -0,0 +1,191 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<issues format="6" by="lint 8.0.0-dev" type="baseline" dependencies="true" variant="all" version="8.0.0-dev">
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `android.net.EthernetNetworkSpecifier#getInterfaceName`"
+        errorLine1="        if (!((EthernetNetworkSpecifier) spec).getInterfaceName().matches(iface)) {"
+        errorLine2="                                               ~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service-t/src/com/android/server/ethernet/EthernetServiceImpl.java"
+            line="224"
+            column="48"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `android.net.UnderlyingNetworkInfo#getInterface`"
+        errorLine1="            delta.migrateTun(info.getOwnerUid(), info.getInterface(),"
+        errorLine2="                                                      ~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service-t/src/com/android/server/net/NetworkStatsFactory.java"
+            line="276"
+            column="55"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `android.net.UnderlyingNetworkInfo#getOwnerUid`"
+        errorLine1="            delta.migrateTun(info.getOwnerUid(), info.getInterface(),"
+        errorLine2="                                  ~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service-t/src/com/android/server/net/NetworkStatsFactory.java"
+            line="276"
+            column="35"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `android.net.UnderlyingNetworkInfo#getUnderlyingInterfaces`"
+        errorLine1="                    info.getUnderlyingInterfaces());"
+        errorLine2="                         ~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service-t/src/com/android/server/net/NetworkStatsFactory.java"
+            line="277"
+            column="26"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `java.net.InetAddress#parseNumericAddress`"
+        errorLine1="                        dnsAddresses.add(InetAddress.parseNumericAddress(address));"
+        errorLine2="                                                     ~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service-t/src/com/android/server/ethernet/EthernetTracker.java"
+            line="875"
+            column="54"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `java.net.InetAddress#parseNumericAddress`"
+        errorLine1="                    staticIpConfigBuilder.setGateway(InetAddress.parseNumericAddress(value));"
+        errorLine2="                                                                 ~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service-t/src/com/android/server/ethernet/EthernetTracker.java"
+            line="870"
+            column="66"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `libcore.io.IoUtils#closeQuietly`"
+        errorLine1="                IoUtils.closeQuietly(os);"
+        errorLine2="                        ~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service-t/src/com/android/server/net/NetworkStatsRecorder.java"
+            line="556"
+            column="25"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `libcore.io.IoUtils#closeQuietly`"
+        errorLine1="                IoUtils.closeQuietly(sockFd);"
+        errorLine2="                        ~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service-t/src/com/android/server/IpSecService.java"
+            line="1309"
+            column="25"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `libcore.io.IoUtils#closeQuietly`"
+        errorLine1="            IoUtils.closeQuietly(mSocket);"
+        errorLine2="                    ~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service-t/src/com/android/server/IpSecService.java"
+            line="1034"
+            column="21"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `new android.net.EthernetNetworkSpecifier`"
+        errorLine1="                .setNetworkSpecifier(new EthernetNetworkSpecifier(ifaceName))"
+        errorLine2="                                     ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service-t/src/com/android/server/ethernet/EthernetNetworkFactory.java"
+            line="156"
+            column="38"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `new android.net.EthernetNetworkSpecifier`"
+        errorLine1="            nc.setNetworkSpecifier(new EthernetNetworkSpecifier(iface));"
+        errorLine2="                                   ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service-t/src/com/android/server/ethernet/EthernetServiceImpl.java"
+            line="218"
+            column="36"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `new android.util.AtomicFile`"
+        errorLine1="        mFile = new AtomicFile(new File(path), logger);"
+        errorLine2="                ~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service-t/src/com/android/server/net/PersistentInt.java"
+            line="53"
+            column="17"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `new java.net.InetSocketAddress`"
+        errorLine1="        super(handler, new RecvBuffer(buffer, new InetSocketAddress()));"
+        errorLine2="                                              ~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service-t/src/com/android/server/connectivity/mdns/MulticastPacketReader.java"
+            line="66"
+            column="47"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Cast from `EthernetNetworkSpecifier` to `NetworkSpecifier` requires API level 31 (current min is 30)"
+        errorLine1="                .setNetworkSpecifier(new EthernetNetworkSpecifier(ifaceName))"
+        errorLine2="                                     ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service-t/src/com/android/server/ethernet/EthernetNetworkFactory.java"
+            line="156"
+            column="38"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Cast from `EthernetNetworkSpecifier` to `NetworkSpecifier` requires API level 31 (current min is 30)"
+        errorLine1="            nc.setNetworkSpecifier(new EthernetNetworkSpecifier(iface));"
+        errorLine2="                                   ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service-t/src/com/android/server/ethernet/EthernetServiceImpl.java"
+            line="218"
+            column="36"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Class requires API level 31 (current min is 30): `android.net.EthernetNetworkSpecifier`"
+        errorLine1="        if (!((EthernetNetworkSpecifier) spec).getInterfaceName().matches(iface)) {"
+        errorLine2="               ~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service-t/src/com/android/server/ethernet/EthernetServiceImpl.java"
+            line="224"
+            column="16"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Class requires API level 31 (current min is 30): `android.net.EthernetNetworkSpecifier`"
+        errorLine1="        if (!(spec instanceof EthernetNetworkSpecifier)) {"
+        errorLine2="                              ~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service-t/src/com/android/server/ethernet/EthernetServiceImpl.java"
+            line="221"
+            column="31"/>
+    </issue>
+
+</issues>
\ No newline at end of file
diff --git a/service-t/native/libs/libnetworkstats/BpfNetworkStats.cpp b/service-t/native/libs/libnetworkstats/BpfNetworkStats.cpp
index fed2979..3101397 100644
--- a/service-t/native/libs/libnetworkstats/BpfNetworkStats.cpp
+++ b/service-t/native/libs/libnetworkstats/BpfNetworkStats.cpp
@@ -41,7 +41,7 @@
 using base::Result;
 
 int bpfGetUidStatsInternal(uid_t uid, StatsValue* stats,
-                           const BpfMap<uint32_t, StatsValue>& appUidStatsMap) {
+                           const BpfMapRO<uint32_t, StatsValue>& appUidStatsMap) {
     auto statsEntry = appUidStatsMap.readValue(uid);
     if (!statsEntry.ok()) {
         *stats = {};
@@ -57,14 +57,14 @@
 }
 
 int bpfGetIfaceStatsInternal(const char* iface, StatsValue* stats,
-                             const BpfMap<uint32_t, StatsValue>& ifaceStatsMap,
-                             const BpfMap<uint32_t, IfaceValue>& ifaceNameMap) {
+                             const BpfMapRO<uint32_t, StatsValue>& ifaceStatsMap,
+                             const BpfMapRO<uint32_t, IfaceValue>& ifaceNameMap) {
     *stats = {};
     int64_t unknownIfaceBytesTotal = 0;
     const auto processIfaceStats =
             [iface, stats, &ifaceNameMap, &unknownIfaceBytesTotal](
                     const uint32_t& key,
-                    const BpfMap<uint32_t, StatsValue>& ifaceStatsMap) -> Result<void> {
+                    const BpfMapRO<uint32_t, StatsValue>& ifaceStatsMap) -> Result<void> {
         char ifname[IFNAMSIZ];
         if (getIfaceNameFromMap(ifaceNameMap, ifaceStatsMap, key, ifname, key,
                                 &unknownIfaceBytesTotal)) {
@@ -90,7 +90,7 @@
 }
 
 int bpfGetIfIndexStatsInternal(uint32_t ifindex, StatsValue* stats,
-                               const BpfMap<uint32_t, StatsValue>& ifaceStatsMap) {
+                               const BpfMapRO<uint32_t, StatsValue>& ifaceStatsMap) {
     auto statsEntry = ifaceStatsMap.readValue(ifindex);
     if (!statsEntry.ok()) {
         *stats = {};
@@ -120,13 +120,13 @@
 }
 
 int parseBpfNetworkStatsDetailInternal(std::vector<stats_line>& lines,
-                                       const BpfMap<StatsKey, StatsValue>& statsMap,
-                                       const BpfMap<uint32_t, IfaceValue>& ifaceMap) {
+                                       const BpfMapRO<StatsKey, StatsValue>& statsMap,
+                                       const BpfMapRO<uint32_t, IfaceValue>& ifaceMap) {
     int64_t unknownIfaceBytesTotal = 0;
     const auto processDetailUidStats =
             [&lines, &unknownIfaceBytesTotal, &ifaceMap](
                     const StatsKey& key,
-                    const BpfMap<StatsKey, StatsValue>& statsMap) -> Result<void> {
+                    const BpfMapRO<StatsKey, StatsValue>& statsMap) -> Result<void> {
         char ifname[IFNAMSIZ];
         if (getIfaceNameFromMap(ifaceMap, statsMap, key.ifaceIndex, ifname, key,
                                 &unknownIfaceBytesTotal)) {
@@ -212,12 +212,12 @@
 }
 
 int parseBpfNetworkStatsDevInternal(std::vector<stats_line>& lines,
-                                    const BpfMap<uint32_t, StatsValue>& statsMap,
-                                    const BpfMap<uint32_t, IfaceValue>& ifaceMap) {
+                                    const BpfMapRO<uint32_t, StatsValue>& statsMap,
+                                    const BpfMapRO<uint32_t, IfaceValue>& ifaceMap) {
     int64_t unknownIfaceBytesTotal = 0;
     const auto processDetailIfaceStats = [&lines, &unknownIfaceBytesTotal, &ifaceMap, &statsMap](
                                              const uint32_t& key, const StatsValue& value,
-                                             const BpfMap<uint32_t, StatsValue>&) {
+                                             const BpfMapRO<uint32_t, StatsValue>&) {
         char ifname[IFNAMSIZ];
         if (getIfaceNameFromMap(ifaceMap, statsMap, key, ifname, key, &unknownIfaceBytesTotal)) {
             return Result<void>();
diff --git a/service-t/native/libs/libnetworkstats/BpfNetworkStatsTest.cpp b/service-t/native/libs/libnetworkstats/BpfNetworkStatsTest.cpp
index 76c56eb..bcc4550 100644
--- a/service-t/native/libs/libnetworkstats/BpfNetworkStatsTest.cpp
+++ b/service-t/native/libs/libnetworkstats/BpfNetworkStatsTest.cpp
@@ -80,19 +80,19 @@
     void SetUp() {
         ASSERT_EQ(0, setrlimitForTest());
 
-        mFakeCookieTagMap = BpfMap<uint64_t, UidTagValue>(BPF_MAP_TYPE_HASH, TEST_MAP_SIZE, 0);
+        mFakeCookieTagMap.resetMap(BPF_MAP_TYPE_HASH, TEST_MAP_SIZE);
         ASSERT_TRUE(mFakeCookieTagMap.isValid());
 
-        mFakeAppUidStatsMap = BpfMap<uint32_t, StatsValue>(BPF_MAP_TYPE_HASH, TEST_MAP_SIZE, 0);
+        mFakeAppUidStatsMap.resetMap(BPF_MAP_TYPE_HASH, TEST_MAP_SIZE);
         ASSERT_TRUE(mFakeAppUidStatsMap.isValid());
 
-        mFakeStatsMap = BpfMap<StatsKey, StatsValue>(BPF_MAP_TYPE_HASH, TEST_MAP_SIZE, 0);
+        mFakeStatsMap.resetMap(BPF_MAP_TYPE_HASH, TEST_MAP_SIZE);
         ASSERT_TRUE(mFakeStatsMap.isValid());
 
-        mFakeIfaceIndexNameMap = BpfMap<uint32_t, IfaceValue>(BPF_MAP_TYPE_HASH, TEST_MAP_SIZE, 0);
+        mFakeIfaceIndexNameMap.resetMap(BPF_MAP_TYPE_HASH, TEST_MAP_SIZE);
         ASSERT_TRUE(mFakeIfaceIndexNameMap.isValid());
 
-        mFakeIfaceStatsMap = BpfMap<uint32_t, StatsValue>(BPF_MAP_TYPE_HASH, TEST_MAP_SIZE, 0);
+        mFakeIfaceStatsMap.resetMap(BPF_MAP_TYPE_HASH, TEST_MAP_SIZE);
         ASSERT_TRUE(mFakeIfaceStatsMap.isValid());
     }
 
diff --git a/service-t/native/libs/libnetworkstats/NetworkTraceHandler.cpp b/service-t/native/libs/libnetworkstats/NetworkTraceHandler.cpp
index ec63e41..9b1b72d 100644
--- a/service-t/native/libs/libnetworkstats/NetworkTraceHandler.cpp
+++ b/service-t/native/libs/libnetworkstats/NetworkTraceHandler.cpp
@@ -18,6 +18,7 @@
 
 #include "netdbpf/NetworkTraceHandler.h"
 
+#include <android-base/macros.h>
 #include <arpa/inet.h>
 #include <bpf/BpfUtils.h>
 #include <log/log.h>
@@ -75,9 +76,35 @@
   uint32_t bytes = 0;
 };
 
-#define AGG_FIELDS(x)                                              \
-  (x).ifindex, (x).uid, (x).tag, (x).sport, (x).dport, (x).egress, \
-      (x).ipProto, (x).tcpFlags
+BundleKey::BundleKey(const PacketTrace& pkt)
+    : ifindex(pkt.ifindex),
+      uid(pkt.uid),
+      tag(pkt.tag),
+      egress(pkt.egress),
+      ipProto(pkt.ipProto),
+      ipVersion(pkt.ipVersion) {
+  switch (ipProto) {
+    case IPPROTO_TCP:
+      tcpFlags = pkt.tcpFlags;
+      FALLTHROUGH_INTENDED;
+    case IPPROTO_DCCP:
+    case IPPROTO_UDP:
+    case IPPROTO_UDPLITE:
+    case IPPROTO_SCTP:
+      localPort = ntohs(pkt.egress ? pkt.sport : pkt.dport);
+      remotePort = ntohs(pkt.egress ? pkt.dport : pkt.sport);
+      break;
+    case IPPROTO_ICMP:
+    case IPPROTO_ICMPV6:
+      icmpType = ntohs(pkt.sport);
+      icmpCode = ntohs(pkt.dport);
+      break;
+  }
+}
+
+#define AGG_FIELDS(x)                                                    \
+  (x).ifindex, (x).uid, (x).tag, (x).egress, (x).ipProto, (x).ipVersion, \
+      (x).tcpFlags, (x).localPort, (x).remotePort, (x).icmpType, (x).icmpCode
 
 std::size_t BundleHash::operator()(const BundleKey& a) const {
   std::size_t seed = 0;
@@ -179,7 +206,7 @@
       dst->set_timestamp(pkt.timestampNs);
       auto* event = dst->set_network_packet();
       event->set_length(pkt.length);
-      Fill(pkt, event);
+      Fill(BundleKey(pkt), event);
     }
     return;
   }
@@ -187,14 +214,13 @@
   uint64_t minTs = std::numeric_limits<uint64_t>::max();
   std::unordered_map<BundleKey, BundleDetails, BundleHash, BundleEq> bundles;
   for (const PacketTrace& pkt : packets) {
-    BundleKey key = pkt;
+    BundleKey key(pkt);
 
     // Dropping fields should remove them from the output and remove them from
-    // the aggregation key. In order to do the latter without changing the hash
-    // function, set the dropped fields to zero.
-    if (mDropTcpFlags) key.tcpFlags = 0;
-    if (mDropLocalPort) (key.egress ? key.sport : key.dport) = 0;
-    if (mDropRemotePort) (key.egress ? key.dport : key.sport) = 0;
+    // the aggregation key. Reset the optionals to indicate omission.
+    if (mDropTcpFlags) key.tcpFlags.reset();
+    if (mDropLocalPort) key.localPort.reset();
+    if (mDropRemotePort) key.remotePort.reset();
 
     minTs = std::min(minTs, pkt.timestampNs);
 
@@ -245,22 +271,18 @@
   }
 }
 
-void NetworkTraceHandler::Fill(const PacketTrace& src,
+void NetworkTraceHandler::Fill(const BundleKey& src,
                                NetworkPacketEvent* event) {
   event->set_direction(src.egress ? TrafficDirection::DIR_EGRESS
                                   : TrafficDirection::DIR_INGRESS);
   event->set_uid(src.uid);
   event->set_tag(src.tag);
 
-  if (!mDropLocalPort) {
-    event->set_local_port(ntohs(src.egress ? src.sport : src.dport));
-  }
-  if (!mDropRemotePort) {
-    event->set_remote_port(ntohs(src.egress ? src.dport : src.sport));
-  }
-  if (!mDropTcpFlags) {
-    event->set_tcp_flags(src.tcpFlags);
-  }
+  if (src.tcpFlags.has_value()) event->set_tcp_flags(*src.tcpFlags);
+  if (src.localPort.has_value()) event->set_local_port(*src.localPort);
+  if (src.remotePort.has_value()) event->set_remote_port(*src.remotePort);
+  if (src.icmpType.has_value()) event->set_icmp_type(*src.icmpType);
+  if (src.icmpCode.has_value()) event->set_icmp_code(*src.icmpCode);
 
   event->set_ip_proto(src.ipProto);
 
diff --git a/service-t/native/libs/libnetworkstats/NetworkTraceHandlerTest.cpp b/service-t/native/libs/libnetworkstats/NetworkTraceHandlerTest.cpp
index f2c1a86..0c4f049 100644
--- a/service-t/native/libs/libnetworkstats/NetworkTraceHandlerTest.cpp
+++ b/service-t/native/libs/libnetworkstats/NetworkTraceHandlerTest.cpp
@@ -113,7 +113,7 @@
           .length = 100,
           .uid = 10,
           .tag = 123,
-          .ipProto = 6,
+          .ipProto = IPPROTO_TCP,
           .tcpFlags = 1,
       },
   };
@@ -138,12 +138,14 @@
           .sport = htons(8080),
           .dport = htons(443),
           .egress = true,
+          .ipProto = IPPROTO_TCP,
       },
       PacketTrace{
           .timestampNs = 2,
           .sport = htons(443),
           .dport = htons(8080),
           .egress = false,
+          .ipProto = IPPROTO_TCP,
       },
   };
 
@@ -161,6 +163,42 @@
               TrafficDirection::DIR_INGRESS);
 }
 
+TEST_F(NetworkTraceHandlerTest, WriteIcmpTypeAndCode) {
+  std::vector<PacketTrace> input = {
+      PacketTrace{
+          .timestampNs = 1,
+          .sport = htons(11),  // type
+          .dport = htons(22),  // code
+          .egress = true,
+          .ipProto = IPPROTO_ICMP,
+      },
+      PacketTrace{
+          .timestampNs = 2,
+          .sport = htons(33),  // type
+          .dport = htons(44),  // code
+          .egress = false,
+          .ipProto = IPPROTO_ICMPV6,
+      },
+  };
+
+  std::vector<TracePacket> events;
+  ASSERT_TRUE(TraceAndSortPackets(input, &events));
+
+  ASSERT_EQ(events.size(), 2);
+  EXPECT_FALSE(events[0].network_packet().has_local_port());
+  EXPECT_FALSE(events[0].network_packet().has_remote_port());
+  EXPECT_THAT(events[0].network_packet().icmp_type(), 11);
+  EXPECT_THAT(events[0].network_packet().icmp_code(), 22);
+  EXPECT_THAT(events[0].network_packet().direction(),
+              TrafficDirection::DIR_EGRESS);
+  EXPECT_FALSE(events[1].network_packet().local_port());
+  EXPECT_FALSE(events[1].network_packet().remote_port());
+  EXPECT_THAT(events[1].network_packet().icmp_type(), 33);
+  EXPECT_THAT(events[1].network_packet().icmp_code(), 44);
+  EXPECT_THAT(events[1].network_packet().direction(),
+              TrafficDirection::DIR_INGRESS);
+}
+
 TEST_F(NetworkTraceHandlerTest, BasicBundling) {
   // TODO: remove this once bundling becomes default. Until then, set arbitrary
   // aggregation threshold to enable bundling.
@@ -168,12 +206,12 @@
   config.set_aggregation_threshold(10);
 
   std::vector<PacketTrace> input = {
-      PacketTrace{.uid = 123, .timestampNs = 2, .length = 200},
-      PacketTrace{.uid = 123, .timestampNs = 1, .length = 100},
-      PacketTrace{.uid = 123, .timestampNs = 4, .length = 300},
+      PacketTrace{.timestampNs = 2, .length = 200, .uid = 123},
+      PacketTrace{.timestampNs = 1, .length = 100, .uid = 123},
+      PacketTrace{.timestampNs = 4, .length = 300, .uid = 123},
 
-      PacketTrace{.uid = 456, .timestampNs = 2, .length = 400},
-      PacketTrace{.uid = 456, .timestampNs = 4, .length = 100},
+      PacketTrace{.timestampNs = 2, .length = 400, .uid = 456},
+      PacketTrace{.timestampNs = 4, .length = 100, .uid = 456},
   };
 
   std::vector<TracePacket> events;
@@ -203,12 +241,12 @@
   config.set_aggregation_threshold(3);
 
   std::vector<PacketTrace> input = {
-      PacketTrace{.uid = 123, .timestampNs = 2, .length = 200},
-      PacketTrace{.uid = 123, .timestampNs = 1, .length = 100},
-      PacketTrace{.uid = 123, .timestampNs = 4, .length = 300},
+      PacketTrace{.timestampNs = 2, .length = 200, .uid = 123},
+      PacketTrace{.timestampNs = 1, .length = 100, .uid = 123},
+      PacketTrace{.timestampNs = 4, .length = 300, .uid = 123},
 
-      PacketTrace{.uid = 456, .timestampNs = 2, .length = 400},
-      PacketTrace{.uid = 456, .timestampNs = 4, .length = 100},
+      PacketTrace{.timestampNs = 2, .length = 400, .uid = 456},
+      PacketTrace{.timestampNs = 4, .length = 100, .uid = 456},
   };
 
   std::vector<TracePacket> events;
@@ -239,12 +277,17 @@
   __be16 b = htons(10001);
   std::vector<PacketTrace> input = {
       // Recall that local is `src` for egress and `dst` for ingress.
-      PacketTrace{.timestampNs = 1, .length = 2, .egress = true, .sport = a},
-      PacketTrace{.timestampNs = 2, .length = 4, .egress = false, .dport = a},
-      PacketTrace{.timestampNs = 3, .length = 6, .egress = true, .sport = b},
-      PacketTrace{.timestampNs = 4, .length = 8, .egress = false, .dport = b},
+      PacketTrace{.timestampNs = 1, .length = 2, .sport = a, .egress = true},
+      PacketTrace{.timestampNs = 2, .length = 4, .dport = a, .egress = false},
+      PacketTrace{.timestampNs = 3, .length = 6, .sport = b, .egress = true},
+      PacketTrace{.timestampNs = 4, .length = 8, .dport = b, .egress = false},
   };
 
+  // Set common fields.
+  for (PacketTrace& pkt : input) {
+    pkt.ipProto = IPPROTO_TCP;
+  }
+
   std::vector<TracePacket> events;
   ASSERT_TRUE(TraceAndSortPackets(input, &events, config));
   ASSERT_EQ(events.size(), 2);
@@ -274,12 +317,17 @@
   __be16 b = htons(80);
   std::vector<PacketTrace> input = {
       // Recall that remote is `dst` for egress and `src` for ingress.
-      PacketTrace{.timestampNs = 1, .length = 2, .egress = true, .dport = a},
-      PacketTrace{.timestampNs = 2, .length = 4, .egress = false, .sport = a},
-      PacketTrace{.timestampNs = 3, .length = 6, .egress = true, .dport = b},
-      PacketTrace{.timestampNs = 4, .length = 8, .egress = false, .sport = b},
+      PacketTrace{.timestampNs = 1, .length = 2, .dport = a, .egress = true},
+      PacketTrace{.timestampNs = 2, .length = 4, .sport = a, .egress = false},
+      PacketTrace{.timestampNs = 3, .length = 6, .dport = b, .egress = true},
+      PacketTrace{.timestampNs = 4, .length = 8, .sport = b, .egress = false},
   };
 
+  // Set common fields.
+  for (PacketTrace& pkt : input) {
+    pkt.ipProto = IPPROTO_TCP;
+  }
+
   std::vector<TracePacket> events;
   ASSERT_TRUE(TraceAndSortPackets(input, &events, config));
   ASSERT_EQ(events.size(), 2);
@@ -306,12 +354,17 @@
   config.set_aggregation_threshold(10);
 
   std::vector<PacketTrace> input = {
-      PacketTrace{.timestampNs = 1, .uid = 123, .length = 1, .tcpFlags = 1},
-      PacketTrace{.timestampNs = 2, .uid = 123, .length = 2, .tcpFlags = 2},
-      PacketTrace{.timestampNs = 3, .uid = 456, .length = 3, .tcpFlags = 1},
-      PacketTrace{.timestampNs = 4, .uid = 456, .length = 4, .tcpFlags = 2},
+      PacketTrace{.timestampNs = 1, .length = 1, .uid = 123, .tcpFlags = 1},
+      PacketTrace{.timestampNs = 2, .length = 2, .uid = 123, .tcpFlags = 2},
+      PacketTrace{.timestampNs = 3, .length = 3, .uid = 456, .tcpFlags = 1},
+      PacketTrace{.timestampNs = 4, .length = 4, .uid = 456, .tcpFlags = 2},
   };
 
+  // Set common fields.
+  for (PacketTrace& pkt : input) {
+    pkt.ipProto = IPPROTO_TCP;
+  }
+
   std::vector<TracePacket> events;
   ASSERT_TRUE(TraceAndSortPackets(input, &events, config));
 
diff --git a/service-t/native/libs/libnetworkstats/NetworkTracePoller.cpp b/service-t/native/libs/libnetworkstats/NetworkTracePoller.cpp
index 80c315a..450f380 100644
--- a/service-t/native/libs/libnetworkstats/NetworkTracePoller.cpp
+++ b/service-t/native/libs/libnetworkstats/NetworkTracePoller.cpp
@@ -25,9 +25,15 @@
 #include <perfetto/tracing/platform.h>
 #include <perfetto/tracing/tracing.h>
 
+#include <unordered_map>
+#include <unordered_set>
+
+#include "netdbpf/BpfNetworkStats.h"
+
 namespace android {
 namespace bpf {
 namespace internal {
+using ::android::base::StringPrintf;
 
 void NetworkTracePoller::PollAndSchedule(perfetto::base::TaskRunner* runner,
                                          uint32_t poll_ms) {
@@ -116,6 +122,28 @@
   return res.ok();
 }
 
+void NetworkTracePoller::TraceIfaces(const std::vector<PacketTrace>& packets) {
+  if (packets.empty()) return;
+
+  std::unordered_set<uint32_t> uniqueIfindex;
+  for (const PacketTrace& pkt : packets) {
+    uniqueIfindex.insert(pkt.ifindex);
+  }
+
+  for (uint32_t ifindex : uniqueIfindex) {
+    char ifname[IF_NAMESIZE] = {};
+    if (if_indextoname(ifindex, ifname) != ifname) continue;
+
+    StatsValue stats = {};
+    if (bpfGetIfIndexStats(ifindex, &stats) != 0) continue;
+
+    std::string rxTrack = StringPrintf("%s [%d] Rx Bytes", ifname, ifindex);
+    std::string txTrack = StringPrintf("%s [%d] Tx Bytes", ifname, ifindex);
+    ATRACE_INT64(rxTrack.c_str(), stats.rxBytes);
+    ATRACE_INT64(txTrack.c_str(), stats.txBytes);
+  }
+}
+
 bool NetworkTracePoller::ConsumeAll() {
   std::scoped_lock<std::mutex> lock(mMutex);
   return ConsumeAllLocked();
@@ -137,6 +165,7 @@
 
   ATRACE_INT("NetworkTracePackets", packets.size());
 
+  TraceIfaces(packets);
   mCallback(packets);
 
   return true;
diff --git a/service-t/native/libs/libnetworkstats/include/netdbpf/BpfNetworkStats.h b/service-t/native/libs/libnetworkstats/include/netdbpf/BpfNetworkStats.h
index ea068fc..8058d05 100644
--- a/service-t/native/libs/libnetworkstats/include/netdbpf/BpfNetworkStats.h
+++ b/service-t/native/libs/libnetworkstats/include/netdbpf/BpfNetworkStats.h
@@ -57,24 +57,25 @@
 
 // For test only
 int bpfGetUidStatsInternal(uid_t uid, StatsValue* stats,
-                           const BpfMap<uint32_t, StatsValue>& appUidStatsMap);
+                           const BpfMapRO<uint32_t, StatsValue>& appUidStatsMap);
 // For test only
 int bpfGetIfaceStatsInternal(const char* iface, StatsValue* stats,
-                             const BpfMap<uint32_t, StatsValue>& ifaceStatsMap,
-                             const BpfMap<uint32_t, IfaceValue>& ifaceNameMap);
+                             const BpfMapRO<uint32_t, StatsValue>& ifaceStatsMap,
+                             const BpfMapRO<uint32_t, IfaceValue>& ifaceNameMap);
 // For test only
 int bpfGetIfIndexStatsInternal(uint32_t ifindex, StatsValue* stats,
-                               const BpfMap<uint32_t, StatsValue>& ifaceStatsMap);
+                               const BpfMapRO<uint32_t, StatsValue>& ifaceStatsMap);
 // For test only
 int parseBpfNetworkStatsDetailInternal(std::vector<stats_line>& lines,
-                                       const BpfMap<StatsKey, StatsValue>& statsMap,
-                                       const BpfMap<uint32_t, IfaceValue>& ifaceMap);
+                                       const BpfMapRO<StatsKey, StatsValue>& statsMap,
+                                       const BpfMapRO<uint32_t, IfaceValue>& ifaceMap);
 // For test only
 int cleanStatsMapInternal(const base::unique_fd& cookieTagMap, const base::unique_fd& tagStatsMap);
 // For test only
 template <class Key>
-int getIfaceNameFromMap(const BpfMap<uint32_t, IfaceValue>& ifaceMap,
-                        const BpfMap<Key, StatsValue>& statsMap, uint32_t ifaceIndex, char* ifname,
+int getIfaceNameFromMap(const BpfMapRO<uint32_t, IfaceValue>& ifaceMap,
+                        const BpfMapRO<Key, StatsValue>& statsMap,
+                        uint32_t ifaceIndex, char* ifname,
                         const Key& curKey, int64_t* unknownIfaceBytesTotal) {
     auto iface = ifaceMap.readValue(ifaceIndex);
     if (!iface.ok()) {
@@ -86,7 +87,7 @@
 }
 
 template <class Key>
-void maybeLogUnknownIface(int ifaceIndex, const BpfMap<Key, StatsValue>& statsMap,
+void maybeLogUnknownIface(int ifaceIndex, const BpfMapRO<Key, StatsValue>& statsMap,
                           const Key& curKey, int64_t* unknownIfaceBytesTotal) {
     // Have we already logged an error?
     if (*unknownIfaceBytesTotal == -1) {
@@ -110,8 +111,8 @@
 
 // For test only
 int parseBpfNetworkStatsDevInternal(std::vector<stats_line>& lines,
-                                    const BpfMap<uint32_t, StatsValue>& statsMap,
-                                    const BpfMap<uint32_t, IfaceValue>& ifaceMap);
+                                    const BpfMapRO<uint32_t, StatsValue>& statsMap,
+                                    const BpfMapRO<uint32_t, IfaceValue>& ifaceMap);
 
 int bpfGetUidStats(uid_t uid, StatsValue* stats);
 int bpfGetIfaceStats(const char* iface, StatsValue* stats);
diff --git a/service-t/native/libs/libnetworkstats/include/netdbpf/NetworkTraceHandler.h b/service-t/native/libs/libnetworkstats/include/netdbpf/NetworkTraceHandler.h
index bc10e68..6bf186a 100644
--- a/service-t/native/libs/libnetworkstats/include/netdbpf/NetworkTraceHandler.h
+++ b/service-t/native/libs/libnetworkstats/include/netdbpf/NetworkTraceHandler.h
@@ -30,15 +30,33 @@
 namespace android {
 namespace bpf {
 
-// BundleKeys are PacketTraces where timestamp and length are ignored.
-using BundleKey = PacketTrace;
+// BundleKey encodes a PacketTrace minus timestamp and length. The key should
+// match many packets over time for interning. For convenience, sport/dport
+// are parsed here as either local/remote port or icmp type/code.
+struct BundleKey {
+  explicit BundleKey(const PacketTrace& pkt);
 
-// BundleKeys are hashed using all fields except timestamp/length.
+  uint32_t ifindex;
+  uint32_t uid;
+  uint32_t tag;
+
+  bool egress;
+  uint8_t ipProto;
+  uint8_t ipVersion;
+
+  std::optional<uint8_t> tcpFlags;
+  std::optional<uint16_t> localPort;
+  std::optional<uint16_t> remotePort;
+  std::optional<uint8_t> icmpType;
+  std::optional<uint8_t> icmpCode;
+};
+
+// BundleKeys are hashed using a simple hash combine.
 struct BundleHash {
   std::size_t operator()(const BundleKey& a) const;
 };
 
-// BundleKeys are equal if all fields except timestamp/length are equal.
+// BundleKeys are equal if all fields are equal.
 struct BundleEq {
   bool operator()(const BundleKey& a, const BundleKey& b) const;
 };
@@ -84,13 +102,13 @@
              NetworkTraceHandler::TraceContext& ctx);
 
  private:
-  // Convert a PacketTrace into a Perfetto trace packet.
-  void Fill(const PacketTrace& src,
+  // Fills in contextual information from a bundle without interning.
+  void Fill(const BundleKey& src,
             ::perfetto::protos::pbzero::NetworkPacketEvent* event);
 
   // Fills in contextual information either inline or via interning.
   ::perfetto::protos::pbzero::NetworkPacketBundle* FillWithInterning(
-      NetworkTraceState* state, const BundleKey& key,
+      NetworkTraceState* state, const BundleKey& src,
       ::perfetto::protos::pbzero::TracePacket* dst);
 
   static internal::NetworkTracePoller sPoller;
diff --git a/service-t/native/libs/libnetworkstats/include/netdbpf/NetworkTracePoller.h b/service-t/native/libs/libnetworkstats/include/netdbpf/NetworkTracePoller.h
index 8433934..092ab64 100644
--- a/service-t/native/libs/libnetworkstats/include/netdbpf/NetworkTracePoller.h
+++ b/service-t/native/libs/libnetworkstats/include/netdbpf/NetworkTracePoller.h
@@ -61,6 +61,11 @@
   void PollAndSchedule(perfetto::base::TaskRunner* runner, uint32_t poll_ms);
   bool ConsumeAllLocked() REQUIRES(mMutex);
 
+  // Record sparse iface stats via atrace. This queries the per-iface stats maps
+  // for any iface present in the vector of packets. This is inexact, but should
+  // have sufficient coverage given these are cumulative counters.
+  void TraceIfaces(const std::vector<PacketTrace>& packets) REQUIRES(mMutex);
+
   std::mutex mMutex;
 
   // Records the number of successfully started active sessions so that only the
diff --git a/service-t/src/com/android/server/ConnectivityServiceInitializer.java b/service-t/src/com/android/server/ConnectivityServiceInitializer.java
index 003ec8c..1ac2f6e 100644
--- a/service-t/src/com/android/server/ConnectivityServiceInitializer.java
+++ b/service-t/src/com/android/server/ConnectivityServiceInitializer.java
@@ -28,7 +28,6 @@
 import com.android.server.ethernet.EthernetService;
 import com.android.server.ethernet.EthernetServiceImpl;
 import com.android.server.nearby.NearbyService;
-import com.android.server.remoteauth.RemoteAuthService;
 import com.android.server.thread.ThreadNetworkService;
 
 /**
@@ -43,7 +42,6 @@
     private final NsdService mNsdService;
     private final NearbyService mNearbyService;
     private final EthernetServiceImpl mEthernetServiceImpl;
-    private final RemoteAuthService mRemoteAuthService;
     private final ThreadNetworkService mThreadNetworkService;
 
     public ConnectivityServiceInitializer(Context context) {
@@ -56,7 +54,6 @@
         mConnectivityNative = createConnectivityNativeService(context);
         mNsdService = createNsdService(context);
         mNearbyService = createNearbyService(context);
-        mRemoteAuthService = createRemoteAuthService(context);
         mThreadNetworkService = createThreadNetworkService(context);
     }
 
@@ -94,12 +91,6 @@
                     /* allowIsolated= */ false);
         }
 
-        if (mRemoteAuthService != null) {
-            Log.i(TAG, "Registering " + RemoteAuthService.SERVICE_NAME);
-            publishBinderService(RemoteAuthService.SERVICE_NAME, mRemoteAuthService,
-                    /* allowIsolated= */ false);
-        }
-
         if (mThreadNetworkService != null) {
             Log.i(TAG, "Registering " + ThreadNetworkManager.SERVICE_NAME);
             publishBinderService(ThreadNetworkManager.SERVICE_NAME, mThreadNetworkService,
@@ -164,19 +155,6 @@
         }
     }
 
-    /** Return RemoteAuth service instance */
-    private RemoteAuthService createRemoteAuthService(final Context context) {
-        if (!SdkLevel.isAtLeastV()) return null;
-        try {
-            return new RemoteAuthService(context);
-        } catch (UnsupportedOperationException e) {
-            // RemoteAuth is not yet supported in all branches
-            // TODO: remove catch clause when it is available.
-            Log.i(TAG, "Skipping unsupported service " + RemoteAuthService.SERVICE_NAME);
-            return null;
-        }
-    }
-
     /**
      * Return EthernetServiceImpl instance or null if current SDK is lower than T or Ethernet
      * service isn't necessary.
diff --git a/service-t/src/com/android/server/NetworkStatsServiceInitializer.java b/service-t/src/com/android/server/NetworkStatsServiceInitializer.java
index 82a4fbd..675e5a1 100644
--- a/service-t/src/com/android/server/NetworkStatsServiceInitializer.java
+++ b/service-t/src/com/android/server/NetworkStatsServiceInitializer.java
@@ -22,6 +22,7 @@
 import android.util.Log;
 
 import com.android.modules.utils.build.SdkLevel;
+import com.android.net.module.util.DeviceConfigUtils;
 import com.android.server.net.NetworkStatsService;
 
 /**
@@ -30,6 +31,8 @@
  */
 public final class NetworkStatsServiceInitializer extends SystemService {
     private static final String TAG = NetworkStatsServiceInitializer.class.getSimpleName();
+    private static final String ENABLE_NETWORK_TRACING = "enable_network_tracing";
+    private final boolean mNetworkTracingFlagEnabled;
     private final NetworkStatsService mStatsService;
 
     public NetworkStatsServiceInitializer(Context context) {
@@ -37,6 +40,8 @@
         // Load JNI libraries used by NetworkStatsService and its dependencies
         System.loadLibrary("service-connectivity");
         mStatsService = maybeCreateNetworkStatsService(context);
+        mNetworkTracingFlagEnabled = DeviceConfigUtils.isTetheringFeatureEnabled(
+            context, ENABLE_NETWORK_TRACING);
     }
 
     @Override
@@ -48,11 +53,10 @@
             TrafficStats.init(getContext());
         }
 
-        // The following code registers the Perfetto Network Trace Handler on non-user builds.
-        // The enhanced tracing is intended to be used for debugging and diagnosing issues. This
-        // is conditional on the build type rather than `isDebuggable` to match the system_server
-        // selinux rules which only allow the Perfetto connection under the same circumstances.
-        if (SdkLevel.isAtLeastU() && !Build.TYPE.equals("user")) {
+        // The following code registers the Perfetto Network Trace Handler. The enhanced tracing
+        // is intended to be used for debugging and diagnosing issues. This is enabled by default
+        // on userdebug/eng builds and flag protected in user builds.
+        if (SdkLevel.isAtLeastU() && (mNetworkTracingFlagEnabled || !Build.TYPE.equals("user"))) {
             Log.i(TAG, "Initializing network tracing hooks");
             NetworkStatsService.nativeInitNetworkTracing();
         }
diff --git a/service-t/src/com/android/server/NsdService.java b/service-t/src/com/android/server/NsdService.java
index 468d7bd..c74f229 100644
--- a/service-t/src/com/android/server/NsdService.java
+++ b/service-t/src/com/android/server/NsdService.java
@@ -26,6 +26,7 @@
 import static android.net.nsd.NsdManager.MDNS_SERVICE_EVENT;
 import static android.net.nsd.NsdManager.RESOLVE_SERVICE_SUCCEEDED;
 import static android.provider.DeviceConfig.NAMESPACE_TETHERING;
+
 import static com.android.modules.utils.build.SdkLevel.isAtLeastU;
 import static com.android.networkstack.apishim.ConstantsShim.REGISTER_NSD_OFFLOAD_ENGINE;
 import static com.android.server.connectivity.mdns.MdnsAdvertiser.AdvertiserMetrics;
@@ -518,9 +519,9 @@
         }
     }
 
+    // TODO: Use a Handler instead of a StateMachine since there are no state changes.
     private class NsdStateMachine extends StateMachine {
 
-        private final DefaultState mDefaultState = new DefaultState();
         private final EnabledState mEnabledState = new EnabledState();
 
         @Override
@@ -590,124 +591,12 @@
 
         NsdStateMachine(String name, Handler handler) {
             super(name, handler);
-            addState(mDefaultState);
-                addState(mEnabledState, mDefaultState);
+            addState(mEnabledState);
             State initialState = mEnabledState;
             setInitialState(initialState);
             setLogRecSize(25);
         }
 
-        class DefaultState extends State {
-            @Override
-            public boolean processMessage(Message msg) {
-                final ClientInfo cInfo;
-                final int clientRequestId = msg.arg2;
-                switch (msg.what) {
-                    case NsdManager.REGISTER_CLIENT:
-                        final ConnectorArgs arg = (ConnectorArgs) msg.obj;
-                        final INsdManagerCallback cb = arg.callback;
-                        try {
-                            cb.asBinder().linkToDeath(arg.connector, 0);
-                            final String tag = "Client" + arg.uid + "-" + mClientNumberId++;
-                            final NetworkNsdReportedMetrics metrics =
-                                    mDeps.makeNetworkNsdReportedMetrics(
-                                            (int) mClock.elapsedRealtime());
-                            cInfo = new ClientInfo(cb, arg.uid, arg.useJavaBackend,
-                                    mServiceLogs.forSubComponent(tag), metrics);
-                            mClients.put(arg.connector, cInfo);
-                        } catch (RemoteException e) {
-                            Log.w(TAG, "Client request id " + clientRequestId
-                                    + " has already died");
-                        }
-                        break;
-                    case NsdManager.UNREGISTER_CLIENT:
-                        final NsdServiceConnector connector = (NsdServiceConnector) msg.obj;
-                        cInfo = mClients.remove(connector);
-                        if (cInfo != null) {
-                            cInfo.expungeAllRequests();
-                            if (cInfo.isPreSClient()) {
-                                mLegacyClientCount -= 1;
-                            }
-                        }
-                        maybeStopMonitoringSocketsIfNoActiveRequest();
-                        maybeScheduleStop();
-                        break;
-                    case NsdManager.DISCOVER_SERVICES:
-                        cInfo = getClientInfoForReply(msg);
-                        if (cInfo != null) {
-                            cInfo.onDiscoverServicesFailedImmediately(clientRequestId,
-                                    NsdManager.FAILURE_INTERNAL_ERROR, true /* isLegacy */);
-                        }
-                       break;
-                    case NsdManager.STOP_DISCOVERY:
-                        cInfo = getClientInfoForReply(msg);
-                        if (cInfo != null) {
-                            cInfo.onStopDiscoveryFailed(
-                                    clientRequestId, NsdManager.FAILURE_INTERNAL_ERROR);
-                        }
-                        break;
-                    case NsdManager.REGISTER_SERVICE:
-                        cInfo = getClientInfoForReply(msg);
-                        if (cInfo != null) {
-                            cInfo.onRegisterServiceFailedImmediately(clientRequestId,
-                                    NsdManager.FAILURE_INTERNAL_ERROR, true /* isLegacy */);
-                        }
-                        break;
-                    case NsdManager.UNREGISTER_SERVICE:
-                        cInfo = getClientInfoForReply(msg);
-                        if (cInfo != null) {
-                            cInfo.onUnregisterServiceFailed(
-                                    clientRequestId, NsdManager.FAILURE_INTERNAL_ERROR);
-                        }
-                        break;
-                    case NsdManager.RESOLVE_SERVICE:
-                        cInfo = getClientInfoForReply(msg);
-                        if (cInfo != null) {
-                            cInfo.onResolveServiceFailedImmediately(clientRequestId,
-                                    NsdManager.FAILURE_INTERNAL_ERROR, true /* isLegacy */);
-                        }
-                        break;
-                    case NsdManager.STOP_RESOLUTION:
-                        cInfo = getClientInfoForReply(msg);
-                        if (cInfo != null) {
-                            cInfo.onStopResolutionFailed(
-                                    clientRequestId, NsdManager.FAILURE_OPERATION_NOT_RUNNING);
-                        }
-                        break;
-                    case NsdManager.REGISTER_SERVICE_CALLBACK:
-                        cInfo = getClientInfoForReply(msg);
-                        if (cInfo != null) {
-                            cInfo.onServiceInfoCallbackRegistrationFailed(
-                                    clientRequestId, NsdManager.FAILURE_BAD_PARAMETERS);
-                        }
-                        break;
-                    case NsdManager.DAEMON_CLEANUP:
-                        maybeStopDaemon();
-                        break;
-                    // This event should be only sent by the legacy (target SDK < S) clients.
-                    // Mark the sending client as legacy.
-                    case NsdManager.DAEMON_STARTUP:
-                        cInfo = getClientInfoForReply(msg);
-                        if (cInfo != null) {
-                            cancelStop();
-                            cInfo.setPreSClient();
-                            mLegacyClientCount += 1;
-                            maybeStartDaemon();
-                        }
-                        break;
-                    default:
-                        Log.e(TAG, "Unhandled " + msg);
-                        return NOT_HANDLED;
-                }
-                return HANDLED;
-            }
-
-            private ClientInfo getClientInfoForReply(Message msg) {
-                final ListenerArgs args = (ListenerArgs) msg.obj;
-                return mClients.get(args.connector);
-            }
-        }
-
         class EnabledState extends State {
             @Override
             public void enter() {
@@ -792,6 +681,11 @@
                 removeRequestMap(clientRequestId, transactionId, clientInfo);
             }
 
+            private ClientInfo getClientInfoForReply(Message msg) {
+                final ListenerArgs args = (ListenerArgs) msg.obj;
+                return mClients.get(args.connector);
+            }
+
             @Override
             public boolean processMessage(Message msg) {
                 final ClientInfo clientInfo;
@@ -1213,7 +1107,51 @@
                     case NsdManager.UNREGISTER_OFFLOAD_ENGINE:
                         mOffloadEngines.unregister((IOffloadEngine) msg.obj);
                         break;
+                    case NsdManager.REGISTER_CLIENT:
+                        final ConnectorArgs arg = (ConnectorArgs) msg.obj;
+                        final INsdManagerCallback cb = arg.callback;
+                        try {
+                            cb.asBinder().linkToDeath(arg.connector, 0);
+                            final String tag = "Client" + arg.uid + "-" + mClientNumberId++;
+                            final NetworkNsdReportedMetrics metrics =
+                                    mDeps.makeNetworkNsdReportedMetrics(
+                                            (int) mClock.elapsedRealtime());
+                            clientInfo = new ClientInfo(cb, arg.uid, arg.useJavaBackend,
+                                    mServiceLogs.forSubComponent(tag), metrics);
+                            mClients.put(arg.connector, clientInfo);
+                        } catch (RemoteException e) {
+                            Log.w(TAG, "Client request id " + clientRequestId
+                                    + " has already died");
+                        }
+                        break;
+                    case NsdManager.UNREGISTER_CLIENT:
+                        final NsdServiceConnector connector = (NsdServiceConnector) msg.obj;
+                        clientInfo = mClients.remove(connector);
+                        if (clientInfo != null) {
+                            clientInfo.expungeAllRequests();
+                            if (clientInfo.isPreSClient()) {
+                                mLegacyClientCount -= 1;
+                            }
+                        }
+                        maybeStopMonitoringSocketsIfNoActiveRequest();
+                        maybeScheduleStop();
+                        break;
+                    case NsdManager.DAEMON_CLEANUP:
+                        maybeStopDaemon();
+                        break;
+                    // This event should be only sent by the legacy (target SDK < S) clients.
+                    // Mark the sending client as legacy.
+                    case NsdManager.DAEMON_STARTUP:
+                        clientInfo = getClientInfoForReply(msg);
+                        if (clientInfo != null) {
+                            cancelStop();
+                            clientInfo.setPreSClient();
+                            mLegacyClientCount += 1;
+                            maybeStartDaemon();
+                        }
+                        break;
                     default:
+                        Log.wtf(TAG, "Unhandled " + msg);
                         return NOT_HANDLED;
                 }
                 return HANDLED;
@@ -1702,15 +1640,20 @@
         am.addOnUidImportanceListener(new UidImportanceListener(handler),
                 mRunningAppActiveImportanceCutoff);
 
+        final MdnsFeatureFlags flags = new MdnsFeatureFlags.Builder()
+                .setIsMdnsOffloadFeatureEnabled(mDeps.isTetheringFeatureNotChickenedOut(
+                        mContext, MdnsFeatureFlags.NSD_FORCE_DISABLE_MDNS_OFFLOAD))
+                .setIncludeInetAddressRecordsInProbing(mDeps.isFeatureEnabled(
+                        mContext, MdnsFeatureFlags.INCLUDE_INET_ADDRESS_RECORDS_IN_PROBING))
+                .setIsExpiredServicesRemovalEnabled(mDeps.isTrunkStableFeatureEnabled(
+                        MdnsFeatureFlags.NSD_EXPIRED_SERVICES_REMOVAL))
+                .build();
         mMdnsSocketClient =
                 new MdnsMultinetworkSocketClient(handler.getLooper(), mMdnsSocketProvider,
                         LOGGER.forSubComponent("MdnsMultinetworkSocketClient"));
         mMdnsDiscoveryManager = deps.makeMdnsDiscoveryManager(new ExecutorProvider(),
-                mMdnsSocketClient, LOGGER.forSubComponent("MdnsDiscoveryManager"));
+                mMdnsSocketClient, LOGGER.forSubComponent("MdnsDiscoveryManager"), flags);
         handler.post(() -> mMdnsSocketClient.setCallback(mMdnsDiscoveryManager));
-        MdnsFeatureFlags flags = new MdnsFeatureFlags.Builder().setIsMdnsOffloadFeatureEnabled(
-                mDeps.isTetheringFeatureNotChickenedOut(
-                        MdnsFeatureFlags.NSD_FORCE_DISABLE_MDNS_OFFLOAD)).build();
         mAdvertiser = deps.makeMdnsAdvertiser(handler.getLooper(), mMdnsSocketProvider,
                 new AdvertiserCallback(), LOGGER.forSubComponent("MdnsAdvertiser"), flags);
         mClock = deps.makeClock();
@@ -1763,8 +1706,15 @@
         /**
          * @see DeviceConfigUtils#isTetheringFeatureNotChickenedOut
          */
-        public boolean isTetheringFeatureNotChickenedOut(String feature) {
-            return DeviceConfigUtils.isTetheringFeatureNotChickenedOut(feature);
+        public boolean isTetheringFeatureNotChickenedOut(Context context, String feature) {
+            return DeviceConfigUtils.isTetheringFeatureNotChickenedOut(context, feature);
+        }
+
+        /**
+         * @see DeviceConfigUtils#isTrunkStableFeatureEnabled
+         */
+        public boolean isTrunkStableFeatureEnabled(String feature) {
+            return DeviceConfigUtils.isTrunkStableFeatureEnabled(feature);
         }
 
         /**
@@ -1772,8 +1722,10 @@
          */
         public MdnsDiscoveryManager makeMdnsDiscoveryManager(
                 @NonNull ExecutorProvider executorProvider,
-                @NonNull MdnsMultinetworkSocketClient socketClient, @NonNull SharedLog sharedLog) {
-            return new MdnsDiscoveryManager(executorProvider, socketClient, sharedLog);
+                @NonNull MdnsMultinetworkSocketClient socketClient, @NonNull SharedLog sharedLog,
+                @NonNull MdnsFeatureFlags featureFlags) {
+            return new MdnsDiscoveryManager(
+                    executorProvider, socketClient, sharedLog, featureFlags);
         }
 
         /**
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 a946bca..28e3924 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsAdvertiser.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsAdvertiser.java
@@ -96,10 +96,11 @@
                 @NonNull Looper looper, @NonNull byte[] packetCreationBuffer,
                 @NonNull MdnsInterfaceAdvertiser.Callback cb,
                 @NonNull String[] deviceHostName,
-                @NonNull SharedLog sharedLog) {
+                @NonNull SharedLog sharedLog,
+                @NonNull MdnsFeatureFlags mdnsFeatureFlags) {
             // Note NetworkInterface is final and not mockable
             return new MdnsInterfaceAdvertiser(socket, initialAddresses, looper,
-                    packetCreationBuffer, cb, deviceHostName, sharedLog);
+                    packetCreationBuffer, cb, deviceHostName, sharedLog, mdnsFeatureFlags);
         }
 
         /**
@@ -394,7 +395,8 @@
             if (advertiser == null) {
                 advertiser = mDeps.makeAdvertiser(socket, addresses, mLooper, mPacketCreationBuffer,
                         mInterfaceAdvertiserCb, mDeviceHostName,
-                        mSharedLog.forSubComponent(socket.getInterface().getName()));
+                        mSharedLog.forSubComponent(socket.getInterface().getName()),
+                        mMdnsFeatureFlags);
                 mAllAdvertisers.put(socket, advertiser);
                 advertiser.start();
             }
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsAnnouncer.java b/service-t/src/com/android/server/connectivity/mdns/MdnsAnnouncer.java
index d9bc643..5812797 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsAnnouncer.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsAnnouncer.java
@@ -110,7 +110,7 @@
             @NonNull MdnsReplySender replySender,
             @Nullable PacketRepeaterCallback<BaseAnnouncementInfo> cb,
             @NonNull SharedLog sharedLog) {
-        super(looper, replySender, cb, sharedLog);
+        super(looper, replySender, cb, sharedLog, MdnsAdvertiser.DBG);
     }
 
     // TODO: Notify MdnsRecordRepository that the records were announced for that service ID,
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsConstants.java b/service-t/src/com/android/server/connectivity/mdns/MdnsConstants.java
index 1251170..b83a6a0 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsConstants.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsConstants.java
@@ -19,6 +19,7 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import java.net.InetAddress;
+import java.net.InetSocketAddress;
 import java.net.UnknownHostException;
 import java.nio.charset.Charset;
 
@@ -42,6 +43,10 @@
     public static final String SUBTYPE_PREFIX = "_";
     private static final String MDNS_IPV4_HOST_ADDRESS = "224.0.0.251";
     private static final String MDNS_IPV6_HOST_ADDRESS = "FF02::FB";
+    public static final InetSocketAddress IPV6_SOCKET_ADDR = new InetSocketAddress(
+            getMdnsIPv6Address(), MDNS_PORT);
+    public static final InetSocketAddress IPV4_SOCKET_ADDR = new InetSocketAddress(
+            getMdnsIPv4Address(), MDNS_PORT);
     private static InetAddress mdnsAddress;
     private MdnsConstants() {
     }
@@ -75,4 +80,4 @@
     public static Charset getUtf8Charset() {
         return UTF_8;
     }
-}
\ No newline at end of file
+}
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 24e9fa8..766f999 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsDiscoveryManager.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsDiscoveryManager.java
@@ -53,6 +53,7 @@
     @NonNull private final Handler handler;
     @Nullable private final HandlerThread handlerThread;
     @NonNull private final MdnsServiceCache serviceCache;
+    @NonNull private final MdnsFeatureFlags mdnsFeatureFlags;
 
     private static class PerSocketServiceTypeClients {
         private final ArrayMap<Pair<String, SocketKey>, MdnsServiceTypeClient> clients =
@@ -117,20 +118,22 @@
     }
 
     public MdnsDiscoveryManager(@NonNull ExecutorProvider executorProvider,
-            @NonNull MdnsSocketClientBase socketClient, @NonNull SharedLog sharedLog) {
+            @NonNull MdnsSocketClientBase socketClient, @NonNull SharedLog sharedLog,
+            @NonNull MdnsFeatureFlags mdnsFeatureFlags) {
         this.executorProvider = executorProvider;
         this.socketClient = socketClient;
         this.sharedLog = sharedLog;
         this.perSocketServiceTypeClients = new PerSocketServiceTypeClients();
+        this.mdnsFeatureFlags = mdnsFeatureFlags;
         if (socketClient.getLooper() != null) {
             this.handlerThread = null;
             this.handler = new Handler(socketClient.getLooper());
-            this.serviceCache = new MdnsServiceCache(socketClient.getLooper());
+            this.serviceCache = new MdnsServiceCache(socketClient.getLooper(), mdnsFeatureFlags);
         } else {
             this.handlerThread = new HandlerThread(MdnsDiscoveryManager.class.getSimpleName());
             this.handlerThread.start();
             this.handler = new Handler(handlerThread.getLooper());
-            this.serviceCache = new MdnsServiceCache(handlerThread.getLooper());
+            this.serviceCache = new MdnsServiceCache(handlerThread.getLooper(), mdnsFeatureFlags);
         }
     }
 
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 9840409..6f7645e 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsFeatureFlags.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsFeatureFlags.java
@@ -20,18 +20,39 @@
  */
 public class MdnsFeatureFlags {
     /**
-     * The feature flag for control whether the  mDNS offload is enabled or not.
+     * A feature flag to control whether the mDNS offload is enabled or not.
      */
     public static final String NSD_FORCE_DISABLE_MDNS_OFFLOAD = "nsd_force_disable_mdns_offload";
 
+    /**
+     * A feature flag to control whether the probing question should include
+     * InetAddressRecords or not.
+     */
+    public static final String INCLUDE_INET_ADDRESS_RECORDS_IN_PROBING =
+            "include_inet_address_records_in_probing";
+    /**
+     * A feature flag to control whether expired services removal should be enabled.
+     */
+    public static final String NSD_EXPIRED_SERVICES_REMOVAL =
+            "nsd_expired_services_removal";
+
     // Flag for offload feature
     public final boolean mIsMdnsOffloadFeatureEnabled;
 
+    // Flag for including InetAddressRecords in probing questions.
+    public final boolean mIncludeInetAddressRecordsInProbing;
+
+    // Flag for expired services removal
+    public final boolean mIsExpiredServicesRemovalEnabled;
+
     /**
      * The constructor for {@link MdnsFeatureFlags}.
      */
-    public MdnsFeatureFlags(boolean isOffloadFeatureEnabled) {
+    public MdnsFeatureFlags(boolean isOffloadFeatureEnabled,
+            boolean includeInetAddressRecordsInProbing, boolean isExpiredServicesRemovalEnabled) {
         mIsMdnsOffloadFeatureEnabled = isOffloadFeatureEnabled;
+        mIncludeInetAddressRecordsInProbing = includeInetAddressRecordsInProbing;
+        mIsExpiredServicesRemovalEnabled = isExpiredServicesRemovalEnabled;
     }
 
 
@@ -44,16 +65,22 @@
     public static final class Builder {
 
         private boolean mIsMdnsOffloadFeatureEnabled;
+        private boolean mIncludeInetAddressRecordsInProbing;
+        private boolean mIsExpiredServicesRemovalEnabled;
 
         /**
          * The constructor for {@link Builder}.
          */
         public Builder() {
             mIsMdnsOffloadFeatureEnabled = false;
+            mIncludeInetAddressRecordsInProbing = false;
+            mIsExpiredServicesRemovalEnabled = true; // Default enabled.
         }
 
         /**
-         * Set if the mDNS offload  feature is enabled.
+         * Set whether the mDNS offload feature is enabled.
+         *
+         * @see #NSD_FORCE_DISABLE_MDNS_OFFLOAD
          */
         public Builder setIsMdnsOffloadFeatureEnabled(boolean isMdnsOffloadFeatureEnabled) {
             mIsMdnsOffloadFeatureEnabled = isMdnsOffloadFeatureEnabled;
@@ -61,11 +88,32 @@
         }
 
         /**
+         * Set whether the probing question should include InetAddressRecords.
+         *
+         * @see #INCLUDE_INET_ADDRESS_RECORDS_IN_PROBING
+         */
+        public Builder setIncludeInetAddressRecordsInProbing(
+                boolean includeInetAddressRecordsInProbing) {
+            mIncludeInetAddressRecordsInProbing = includeInetAddressRecordsInProbing;
+            return this;
+        }
+
+        /**
+         * Set whether the expired services removal is enabled.
+         *
+         * @see #NSD_EXPIRED_SERVICES_REMOVAL
+         */
+        public Builder setIsExpiredServicesRemovalEnabled(boolean isExpiredServicesRemovalEnabled) {
+            mIsExpiredServicesRemovalEnabled = isExpiredServicesRemovalEnabled;
+            return this;
+        }
+
+        /**
          * Builds a {@link MdnsFeatureFlags} with the arguments supplied to this builder.
          */
         public MdnsFeatureFlags build() {
-            return new MdnsFeatureFlags(mIsMdnsOffloadFeatureEnabled);
+            return new MdnsFeatureFlags(mIsMdnsOffloadFeatureEnabled,
+                    mIncludeInetAddressRecordsInProbing, mIsExpiredServicesRemovalEnabled);
         }
-
     }
 }
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsInetAddressRecord.java b/service-t/src/com/android/server/connectivity/mdns/MdnsInetAddressRecord.java
index dd8a526..973fd96 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsInetAddressRecord.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsInetAddressRecord.java
@@ -18,7 +18,7 @@
 
 import android.annotation.Nullable;
 
-import com.android.internal.annotations.VisibleForTesting;
+import androidx.annotation.VisibleForTesting;
 
 import java.io.IOException;
 import java.net.Inet4Address;
@@ -29,7 +29,7 @@
 import java.util.Objects;
 
 /** An mDNS "AAAA" or "A" record, which holds an IPv6 or IPv4 address. */
-@VisibleForTesting
+@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
 public class MdnsInetAddressRecord extends MdnsRecord {
     @Nullable private Inet6Address inet6Address;
     @Nullable private Inet4Address inet4Address;
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 40dfd57..42a6b0d 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsInterfaceAdvertiser.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsInterfaceAdvertiser.java
@@ -68,6 +68,8 @@
 
     @NonNull
     private final SharedLog mSharedLog;
+    @NonNull
+    private final byte[] mPacketCreationBuffer;
 
     /**
      * Callbacks called by {@link MdnsInterfaceAdvertiser} to report status updates.
@@ -150,8 +152,8 @@
         /** @see MdnsRecordRepository */
         @NonNull
         public MdnsRecordRepository makeRecordRepository(@NonNull Looper looper,
-                @NonNull String[] deviceHostName) {
-            return new MdnsRecordRepository(looper, deviceHostName);
+                @NonNull String[] deviceHostName, @NonNull MdnsFeatureFlags mdnsFeatureFlags) {
+            return new MdnsRecordRepository(looper, deviceHostName, mdnsFeatureFlags);
         }
 
         /** @see MdnsReplySender */
@@ -161,7 +163,7 @@
                 @NonNull SharedLog sharedLog) {
             return new MdnsReplySender(looper, socket, packetCreationBuffer,
                     sharedLog.forSubComponent(
-                            MdnsReplySender.class.getSimpleName() + "/" + interfaceTag));
+                            MdnsReplySender.class.getSimpleName() + "/" + interfaceTag), DBG);
         }
 
         /** @see MdnsAnnouncer */
@@ -187,22 +189,25 @@
     public MdnsInterfaceAdvertiser(@NonNull MdnsInterfaceSocket socket,
             @NonNull List<LinkAddress> initialAddresses, @NonNull Looper looper,
             @NonNull byte[] packetCreationBuffer, @NonNull Callback cb,
-            @NonNull String[] deviceHostName, @NonNull SharedLog sharedLog) {
+            @NonNull String[] deviceHostName, @NonNull SharedLog sharedLog,
+            @NonNull MdnsFeatureFlags mdnsFeatureFlags) {
         this(socket, initialAddresses, looper, packetCreationBuffer, cb,
-                new Dependencies(), deviceHostName, sharedLog);
+                new Dependencies(), deviceHostName, sharedLog, mdnsFeatureFlags);
     }
 
     public MdnsInterfaceAdvertiser(@NonNull MdnsInterfaceSocket socket,
             @NonNull List<LinkAddress> initialAddresses, @NonNull Looper looper,
             @NonNull byte[] packetCreationBuffer, @NonNull Callback cb, @NonNull Dependencies deps,
-            @NonNull String[] deviceHostName, @NonNull SharedLog sharedLog) {
-        mRecordRepository = deps.makeRecordRepository(looper, deviceHostName);
+            @NonNull String[] deviceHostName, @NonNull SharedLog sharedLog,
+            @NonNull MdnsFeatureFlags mdnsFeatureFlags) {
+        mRecordRepository = deps.makeRecordRepository(looper, deviceHostName, mdnsFeatureFlags);
         mRecordRepository.updateAddresses(initialAddresses);
         mSocket = socket;
         mCb = cb;
         mCbHandler = new Handler(looper);
         mReplySender = deps.makeReplySender(sharedLog.getTag(), looper, socket,
                 packetCreationBuffer, sharedLog);
+        mPacketCreationBuffer = packetCreationBuffer;
         mAnnouncer = deps.makeMdnsAnnouncer(sharedLog.getTag(), looper, mReplySender,
                 mAnnouncingCallback, sharedLog);
         mProber = deps.makeMdnsProber(sharedLog.getTag(), looper, mReplySender, mProbingCallback,
@@ -370,7 +375,7 @@
         // happen when the incoming packet has answer records (not a question), so there will be no
         // answer. One exception is simultaneous probe tiebreaking (rfc6762 8.2), in which case the
         // conflicting service is still probing and won't reply either.
-        final MdnsRecordRepository.ReplyInfo answers = mRecordRepository.getReply(packet, src);
+        final MdnsReplyInfo answers = mRecordRepository.getReply(packet, src);
 
         if (answers == null) return;
         mReplySender.queueReply(answers);
@@ -388,12 +393,13 @@
      * @param serviceId The serviceId.
      * @return the raw offload payload
      */
+    @NonNull
     public byte[] getRawOffloadPayload(int serviceId) {
         try {
-            return MdnsUtils.createRawDnsPacket(mReplySender.getPacketCreationBuffer(),
+            return MdnsUtils.createRawDnsPacket(mPacketCreationBuffer,
                     mRecordRepository.getOffloadPacket(serviceId));
         } catch (IOException | IllegalArgumentException e) {
-            mSharedLog.wtf("Cannot create rawOffloadPacket: " + e.getMessage());
+            mSharedLog.wtf("Cannot create rawOffloadPacket: ", e);
             return new byte[0];
         }
     }
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 7fa3f84..1fabd49 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsPacket.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsPacket.java
@@ -31,6 +31,7 @@
 public class MdnsPacket {
     private static final String TAG = MdnsPacket.class.getSimpleName();
 
+    public final int transactionId;
     public final int flags;
     @NonNull
     public final List<MdnsRecord> questions;
@@ -46,6 +47,15 @@
             @NonNull List<MdnsRecord> answers,
             @NonNull List<MdnsRecord> authorityRecords,
             @NonNull List<MdnsRecord> additionalRecords) {
+        this(0, flags, questions, answers, authorityRecords, additionalRecords);
+    }
+
+    MdnsPacket(int transactionId, int flags,
+            @NonNull List<MdnsRecord> questions,
+            @NonNull List<MdnsRecord> answers,
+            @NonNull List<MdnsRecord> authorityRecords,
+            @NonNull List<MdnsRecord> additionalRecords) {
+        this.transactionId = transactionId;
         this.flags = flags;
         this.questions = Collections.unmodifiableList(questions);
         this.answers = Collections.unmodifiableList(answers);
@@ -70,15 +80,16 @@
      */
     @NonNull
     public static MdnsPacket parse(@NonNull MdnsPacketReader reader) throws ParseException {
+        final int transactionId;
         final int flags;
         try {
-            reader.readUInt16(); // transaction ID (not used)
+            transactionId = reader.readUInt16();
             flags = reader.readUInt16();
         } catch (EOFException e) {
             throw new ParseException(MdnsResponseErrorCode.ERROR_END_OF_FILE,
                     "Reached the end of the mDNS response unexpectedly.", e);
         }
-        return parseRecordsSection(reader, flags);
+        return parseRecordsSection(reader, flags, transactionId);
     }
 
     /**
@@ -86,8 +97,8 @@
      *
      * The records section starts with the questions count, just after the packet flags.
      */
-    public static MdnsPacket parseRecordsSection(@NonNull MdnsPacketReader reader, int flags)
-            throws ParseException {
+    public static MdnsPacket parseRecordsSection(@NonNull MdnsPacketReader reader, int flags,
+            int transactionId) throws ParseException {
         try {
             final int numQuestions = reader.readUInt16();
             final int numAnswers = reader.readUInt16();
@@ -99,7 +110,7 @@
             final ArrayList<MdnsRecord> authority = parseRecords(reader, numAuthority, false);
             final ArrayList<MdnsRecord> additional = parseRecords(reader, numAdditional, false);
 
-            return new MdnsPacket(flags, questions, answers, authority, additional);
+            return new MdnsPacket(transactionId, flags, questions, answers, authority, additional);
         } catch (EOFException e) {
             throw new ParseException(MdnsResponseErrorCode.ERROR_END_OF_FILE,
                     "Reached the end of the mDNS response unexpectedly.", e);
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsPacketRepeater.java b/service-t/src/com/android/server/connectivity/mdns/MdnsPacketRepeater.java
index fd0f5c9..e84cead 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsPacketRepeater.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsPacketRepeater.java
@@ -16,8 +16,8 @@
 
 package com.android.server.connectivity.mdns;
 
-import static com.android.server.connectivity.mdns.MdnsRecordRepository.IPV4_ADDR;
-import static com.android.server.connectivity.mdns.MdnsRecordRepository.IPV6_ADDR;
+import static com.android.server.connectivity.mdns.MdnsConstants.IPV4_SOCKET_ADDR;
+import static com.android.server.connectivity.mdns.MdnsConstants.IPV6_SOCKET_ADDR;
 
 import android.annotation.NonNull;
 import android.annotation.Nullable;
@@ -38,9 +38,8 @@
  */
 @RequiresApi(Build.VERSION_CODES.TIRAMISU)
 public abstract class MdnsPacketRepeater<T extends MdnsPacketRepeater.Request> {
-    private static final boolean DBG = MdnsAdvertiser.DBG;
     private static final InetSocketAddress[] ALL_ADDRS = new InetSocketAddress[] {
-            IPV4_ADDR, IPV6_ADDR
+            IPV4_SOCKET_ADDR, IPV6_SOCKET_ADDR
     };
 
     @NonNull
@@ -51,6 +50,7 @@
     private final PacketRepeaterCallback<T> mCb;
     @NonNull
     private final SharedLog mSharedLog;
+    private final boolean mEnableDebugLog;
 
     /**
      * Status callback from {@link MdnsPacketRepeater}.
@@ -111,7 +111,7 @@
             }
 
             final MdnsPacket packet = request.getPacket(index);
-            if (DBG) {
+            if (mEnableDebugLog) {
                 mSharedLog.v("Sending packets for iteration " + index + " out of "
                         + request.getNumSends() + " for ID " + msg.what);
             }
@@ -134,7 +134,7 @@
                 // likely not to be available since the device is in deep sleep anyway.
                 final long delay = request.getDelayMs(nextIndex);
                 sendMessageDelayed(obtainMessage(msg.what, nextIndex, 0, request), delay);
-                if (DBG) mSharedLog.v("Scheduled next packet in " + delay + "ms");
+                if (mEnableDebugLog) mSharedLog.v("Scheduled next packet in " + delay + "ms");
             }
 
             // Call onSent after scheduling the next run, to allow the callback to cancel it
@@ -145,15 +145,17 @@
     }
 
     protected MdnsPacketRepeater(@NonNull Looper looper, @NonNull MdnsReplySender replySender,
-            @Nullable PacketRepeaterCallback<T> cb, @NonNull SharedLog sharedLog) {
+            @Nullable PacketRepeaterCallback<T> cb, @NonNull SharedLog sharedLog,
+            boolean enableDebugLog) {
         mHandler = new ProbeHandler(looper);
         mReplySender = replySender;
         mCb = cb;
         mSharedLog = sharedLog;
+        mEnableDebugLog = enableDebugLog;
     }
 
     protected void startSending(int id, @NonNull T request, long initialDelayMs) {
-        if (DBG) {
+        if (mEnableDebugLog) {
             mSharedLog.v("Starting send with id " + id + ", request "
                     + request.getClass().getSimpleName() + ", delay " + initialDelayMs);
         }
@@ -172,7 +174,7 @@
         // all in the handler queue; unless this method is called from a message, but the current
         // message cannot be cancelled.
         if (mHandler.hasMessages(id)) {
-            if (DBG) {
+            if (mEnableDebugLog) {
                 mSharedLog.v("Stopping send on id " + id);
             }
             mHandler.removeMessages(id);
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsPointerRecord.java b/service-t/src/com/android/server/connectivity/mdns/MdnsPointerRecord.java
index c88ead0..41cc380 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsPointerRecord.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsPointerRecord.java
@@ -18,14 +18,15 @@
 
 import android.annotation.Nullable;
 
-import com.android.internal.annotations.VisibleForTesting;
+import androidx.annotation.VisibleForTesting;
+
 import com.android.server.connectivity.mdns.util.MdnsUtils;
 
 import java.io.IOException;
 import java.util.Arrays;
 
 /** An mDNS "PTR" record, which holds a name (the "pointer"). */
-@VisibleForTesting
+@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
 public class MdnsPointerRecord extends MdnsRecord {
     private String[] pointer;
 
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsProber.java b/service-t/src/com/android/server/connectivity/mdns/MdnsProber.java
index f2b562a..e88947a 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsProber.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsProber.java
@@ -40,9 +40,8 @@
     private static final long CONFLICT_RETRY_DELAY_MS = 5_000L;
 
     public MdnsProber(@NonNull Looper looper, @NonNull MdnsReplySender replySender,
-            @NonNull PacketRepeaterCallback<ProbingInfo> cb,
-            @NonNull SharedLog sharedLog) {
-        super(looper, replySender, cb, sharedLog);
+            @NonNull PacketRepeaterCallback<ProbingInfo> cb, @NonNull SharedLog sharedLog) {
+        super(looper, replySender, cb, sharedLog, MdnsAdvertiser.DBG);
     }
 
     /** Probing request to send with {@link MdnsProber}. */
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 f532372..73c1758 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsRecordRepository.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsRecordRepository.java
@@ -16,6 +16,8 @@
 
 package com.android.server.connectivity.mdns;
 
+import static com.android.server.connectivity.mdns.MdnsConstants.IPV4_SOCKET_ADDR;
+import static com.android.server.connectivity.mdns.MdnsConstants.IPV6_SOCKET_ADDR;
 import static com.android.server.connectivity.mdns.MdnsConstants.NO_PACKET;
 
 import android.annotation.NonNull;
@@ -79,11 +81,6 @@
     private static final String[] DNS_SD_SERVICE_TYPE =
             new String[] { "_services", "_dns-sd", "_udp", LOCAL_TLD };
 
-    public static final InetSocketAddress IPV6_ADDR = new InetSocketAddress(
-            MdnsConstants.getMdnsIPv6Address(), MdnsConstants.MDNS_PORT);
-    public static final InetSocketAddress IPV4_ADDR = new InetSocketAddress(
-            MdnsConstants.getMdnsIPv4Address(), MdnsConstants.MDNS_PORT);
-
     @NonNull
     private final Random mDelayGenerator = new Random();
     // Map of service unique ID -> records for service
@@ -95,16 +92,19 @@
     private final Looper mLooper;
     @NonNull
     private final String[] mDeviceHostname;
+    private final MdnsFeatureFlags mMdnsFeatureFlags;
 
-    public MdnsRecordRepository(@NonNull Looper looper, @NonNull String[] deviceHostname) {
-        this(looper, new Dependencies(), deviceHostname);
+    public MdnsRecordRepository(@NonNull Looper looper, @NonNull String[] deviceHostname,
+            @NonNull MdnsFeatureFlags mdnsFeatureFlags) {
+        this(looper, new Dependencies(), deviceHostname, mdnsFeatureFlags);
     }
 
     @VisibleForTesting
     public MdnsRecordRepository(@NonNull Looper looper, @NonNull Dependencies deps,
-            @NonNull String[] deviceHostname) {
+            @NonNull String[] deviceHostname, @NonNull MdnsFeatureFlags mdnsFeatureFlags) {
         mDeviceHostname = deviceHostname;
         mLooper = looper;
+        mMdnsFeatureFlags = mdnsFeatureFlags;
     }
 
     /**
@@ -354,7 +354,8 @@
     }
 
     private MdnsProber.ProbingInfo makeProbingInfo(int serviceId,
-            @NonNull MdnsServiceRecord srvRecord) {
+            @NonNull MdnsServiceRecord srvRecord,
+            @NonNull List<MdnsInetAddressRecord> inetAddressRecords) {
         final List<MdnsRecord> probingRecords = new ArrayList<>();
         // Probe with cacheFlush cleared; it is set when announcing, as it was verified unique:
         // RFC6762 10.2
@@ -366,6 +367,15 @@
                 srvRecord.getServicePort(),
                 srvRecord.getServiceHost()));
 
+        for (MdnsInetAddressRecord inetAddressRecord : inetAddressRecords) {
+            probingRecords.add(new MdnsInetAddressRecord(inetAddressRecord.getName(),
+                    0L /* receiptTimeMillis */,
+                    false /* cacheFlush */,
+                    inetAddressRecord.getTtl(),
+                    inetAddressRecord.getInet4Address() == null
+                            ? inetAddressRecord.getInet6Address()
+                            : inetAddressRecord.getInet4Address()));
+        }
         return new MdnsProber.ProbingInfo(serviceId, probingRecords);
     }
 
@@ -455,44 +465,13 @@
     }
 
     /**
-     * Info about a reply to be sent.
-     */
-    public static class ReplyInfo {
-        @NonNull
-        public final List<MdnsRecord> answers;
-        @NonNull
-        public final List<MdnsRecord> additionalAnswers;
-        public final long sendDelayMs;
-        @NonNull
-        public final InetSocketAddress destination;
-
-        public ReplyInfo(
-                @NonNull List<MdnsRecord> answers,
-                @NonNull List<MdnsRecord> additionalAnswers,
-                long sendDelayMs,
-                @NonNull InetSocketAddress destination) {
-            this.answers = answers;
-            this.additionalAnswers = additionalAnswers;
-            this.sendDelayMs = sendDelayMs;
-            this.destination = destination;
-        }
-
-        @Override
-        public String toString() {
-            return "{ReplyInfo to " + destination + ", answers: " + answers.size()
-                    + ", additionalAnswers: " + additionalAnswers.size()
-                    + ", sendDelayMs " + sendDelayMs + "}";
-        }
-    }
-
-    /**
      * Get the reply to send to an incoming packet.
      *
      * @param packet The incoming packet.
      * @param src The source address of the incoming packet.
      */
     @Nullable
-    public ReplyInfo getReply(MdnsPacket packet, InetSocketAddress src) {
+    public MdnsReplyInfo getReply(MdnsPacket packet, InetSocketAddress src) {
         final long now = SystemClock.elapsedRealtime();
         final boolean replyUnicast = (packet.flags & MdnsConstants.QCLASS_UNICAST) != 0;
         final ArrayList<MdnsRecord> additionalAnswerRecords = new ArrayList<>();
@@ -543,9 +522,9 @@
         if (replyUnicast) {
             dest = src;
         } else if (src.getAddress() instanceof Inet4Address) {
-            dest = IPV4_ADDR;
+            dest = IPV4_SOCKET_ADDR;
         } else {
-            dest = IPV6_ADDR;
+            dest = IPV6_SOCKET_ADDR;
         }
 
         // Build the list of answer records from their RecordInfo
@@ -559,7 +538,7 @@
             answerRecords.add(info.record);
         }
 
-        return new ReplyInfo(answerRecords, additionalAnswerRecords, delayMs, dest);
+        return new MdnsReplyInfo(answerRecords, additionalAnswerRecords, delayMs, dest);
     }
 
     /**
@@ -858,6 +837,18 @@
         return conflicting;
     }
 
+    private List<MdnsInetAddressRecord> makeProbingInetAddressRecords() {
+        final List<MdnsInetAddressRecord> records = new ArrayList<>();
+        if (mMdnsFeatureFlags.mIncludeInetAddressRecordsInProbing) {
+            for (RecordInfo<?> record : mGeneralRecords) {
+                if (record.record instanceof MdnsInetAddressRecord) {
+                    records.add((MdnsInetAddressRecord) record.record);
+                }
+            }
+        }
+        return records;
+    }
+
     /**
      * (Re)set a service to the probing state.
      * @return The {@link MdnsProber.ProbingInfo} to send for probing.
@@ -868,7 +859,8 @@
         if (registration == null) return null;
 
         registration.setProbing(true);
-        return makeProbingInfo(serviceId, registration.srvRecord.record);
+        return makeProbingInfo(
+                serviceId, registration.srvRecord.record, makeProbingInetAddressRecords());
     }
 
     /**
@@ -904,7 +896,8 @@
         final ServiceRegistration newService = new ServiceRegistration(mDeviceHostname, newInfo,
                 existing.subtype, existing.repliedServiceCount, existing.sentPacketCount);
         mServices.put(serviceId, newService);
-        return makeProbingInfo(serviceId, newService.srvRecord.record);
+        return makeProbingInfo(
+                serviceId, newService.srvRecord.record, makeProbingInetAddressRecords());
     }
 
     /**
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsReplyInfo.java b/service-t/src/com/android/server/connectivity/mdns/MdnsReplyInfo.java
new file mode 100644
index 0000000..ce61b54
--- /dev/null
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsReplyInfo.java
@@ -0,0 +1,53 @@
+/*
+ * 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.connectivity.mdns;
+
+import android.annotation.NonNull;
+
+import java.net.InetSocketAddress;
+import java.util.List;
+
+/**
+ * Info about a mDNS reply to be sent.
+ */
+public final class MdnsReplyInfo {
+    @NonNull
+    public final List<MdnsRecord> answers;
+    @NonNull
+    public final List<MdnsRecord> additionalAnswers;
+    public final long sendDelayMs;
+    @NonNull
+    public final InetSocketAddress destination;
+
+    public MdnsReplyInfo(
+            @NonNull List<MdnsRecord> answers,
+            @NonNull List<MdnsRecord> additionalAnswers,
+            long sendDelayMs,
+            @NonNull InetSocketAddress destination) {
+        this.answers = answers;
+        this.additionalAnswers = additionalAnswers;
+        this.sendDelayMs = sendDelayMs;
+        this.destination = destination;
+    }
+
+    @Override
+    public String toString() {
+        return "{MdnsReplyInfo to " + destination + ", answers: " + answers.size()
+                + ", additionalAnswers: " + additionalAnswers.size()
+                + ", sendDelayMs " + sendDelayMs + "}";
+    }
+}
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsReplySender.java b/service-t/src/com/android/server/connectivity/mdns/MdnsReplySender.java
index 3d64b5a..ea3af5e 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsReplySender.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsReplySender.java
@@ -26,7 +26,6 @@
 import android.os.Message;
 
 import com.android.net.module.util.SharedLog;
-import com.android.server.connectivity.mdns.MdnsRecordRepository.ReplyInfo;
 import com.android.server.connectivity.mdns.util.MdnsUtils;
 
 import java.io.IOException;
@@ -45,7 +44,6 @@
  */
 @RequiresApi(Build.VERSION_CODES.TIRAMISU)
 public class MdnsReplySender {
-    private static final boolean DBG = MdnsAdvertiser.DBG;
     private static final int MSG_SEND = 1;
     private static final int PACKET_NOT_SENT = 0;
     private static final int PACKET_SENT = 1;
@@ -58,24 +56,27 @@
     private final byte[] mPacketCreationBuffer;
     @NonNull
     private final SharedLog mSharedLog;
+    private final boolean mEnableDebugLog;
 
     public MdnsReplySender(@NonNull Looper looper, @NonNull MdnsInterfaceSocket socket,
-            @NonNull byte[] packetCreationBuffer, @NonNull SharedLog sharedLog) {
+            @NonNull byte[] packetCreationBuffer, @NonNull SharedLog sharedLog,
+            boolean enableDebugLog) {
         mHandler = new SendHandler(looper);
         mSocket = socket;
         mPacketCreationBuffer = packetCreationBuffer;
         mSharedLog = sharedLog;
+        mEnableDebugLog = enableDebugLog;
     }
 
     /**
      * Queue a reply to be sent when its send delay expires.
      */
-    public void queueReply(@NonNull ReplyInfo reply) {
+    public void queueReply(@NonNull MdnsReplyInfo reply) {
         ensureRunningOnHandlerThread(mHandler);
         // TODO: implement response aggregation (RFC 6762 6.4)
         mHandler.sendMessageDelayed(mHandler.obtainMessage(MSG_SEND, reply), reply.sendDelayMs);
 
-        if (DBG) {
+        if (mEnableDebugLog) {
             mSharedLog.v("Scheduling " + reply);
         }
     }
@@ -98,11 +99,6 @@
         return PACKET_SENT;
     }
 
-    /** Get the packetCreationBuffer */
-    public byte[] getPacketCreationBuffer() {
-        return mPacketCreationBuffer;
-    }
-
     /**
      * Cancel all pending sends.
      */
@@ -118,8 +114,8 @@
 
         @Override
         public void handleMessage(@NonNull Message msg) {
-            final ReplyInfo replyInfo = (ReplyInfo) msg.obj;
-            if (DBG) mSharedLog.v("Sending " + replyInfo);
+            final MdnsReplyInfo replyInfo = (MdnsReplyInfo) msg.obj;
+            if (mEnableDebugLog) mSharedLog.v("Sending " + replyInfo);
 
             final int flags = 0x8400; // Response, authoritative (rfc6762 18.4)
             final MdnsPacket packet = new MdnsPacket(flags,
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsResponseDecoder.java b/service-t/src/com/android/server/connectivity/mdns/MdnsResponseDecoder.java
index a3cc0eb..050913f 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsResponseDecoder.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsResponseDecoder.java
@@ -90,14 +90,14 @@
 
         final MdnsPacket mdnsPacket;
         try {
-            reader.readUInt16(); // transaction ID (not used)
+            final int transactionId = reader.readUInt16();
             int flags = reader.readUInt16();
             if ((flags & MdnsConstants.FLAGS_RESPONSE_MASK) != MdnsConstants.FLAGS_RESPONSE) {
                 throw new MdnsPacket.ParseException(
                         MdnsResponseErrorCode.ERROR_NOT_RESPONSE_MESSAGE, "Not a response", null);
             }
 
-            mdnsPacket = MdnsPacket.parseRecordsSection(reader, flags);
+            mdnsPacket = MdnsPacket.parseRecordsSection(reader, flags, transactionId);
             if (mdnsPacket.answers.size() < 1) {
                 throw new MdnsPacket.ParseException(
                         MdnsResponseErrorCode.ERROR_NO_ANSWERS, "Response has no answers",
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsServiceCache.java b/service-t/src/com/android/server/connectivity/mdns/MdnsServiceCache.java
index ec6af9b..d3493c7 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsServiceCache.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsServiceCache.java
@@ -42,7 +42,7 @@
  *  to their default value (0, false or null).
  */
 public class MdnsServiceCache {
-    private static class CacheKey {
+    static class CacheKey {
         @NonNull final String mLowercaseServiceType;
         @NonNull final SocketKey mSocketKey;
 
@@ -72,27 +72,33 @@
      */
     @NonNull
     private final ArrayMap<CacheKey, List<MdnsResponse>> mCachedServices = new ArrayMap<>();
+    /**
+     * A map of service expire callbacks. Key is composed of service type and socket and value is
+     * the callback listener.
+     */
+    @NonNull
+    private final ArrayMap<CacheKey, ServiceExpiredCallback> mCallbacks = new ArrayMap<>();
     @NonNull
     private final Handler mHandler;
+    @NonNull
+    private final MdnsFeatureFlags mMdnsFeatureFlags;
 
-    public MdnsServiceCache(@NonNull Looper looper) {
+    public MdnsServiceCache(@NonNull Looper looper, @NonNull MdnsFeatureFlags mdnsFeatureFlags) {
         mHandler = new Handler(looper);
+        mMdnsFeatureFlags = mdnsFeatureFlags;
     }
 
     /**
      * Get the cache services which are queried from given service type and socket.
      *
-     * @param serviceType the target service type.
-     * @param socketKey the target socket
+     * @param cacheKey the target CacheKey.
      * @return the set of services which matches the given service type.
      */
     @NonNull
-    public List<MdnsResponse> getCachedServices(@NonNull String serviceType,
-            @NonNull SocketKey socketKey) {
+    public List<MdnsResponse> getCachedServices(@NonNull CacheKey cacheKey) {
         ensureRunningOnHandlerThread(mHandler);
-        final CacheKey key = new CacheKey(serviceType, socketKey);
-        return mCachedServices.containsKey(key)
-                ? Collections.unmodifiableList(new ArrayList<>(mCachedServices.get(key)))
+        return mCachedServices.containsKey(cacheKey)
+                ? Collections.unmodifiableList(new ArrayList<>(mCachedServices.get(cacheKey)))
                 : Collections.emptyList();
     }
 
@@ -117,16 +123,13 @@
      * Get the cache service.
      *
      * @param serviceName the target service name.
-     * @param serviceType the target service type.
-     * @param socketKey the target socket
+     * @param cacheKey the target CacheKey.
      * @return the service which matches given conditions.
      */
     @Nullable
-    public MdnsResponse getCachedService(@NonNull String serviceName,
-            @NonNull String serviceType, @NonNull SocketKey socketKey) {
+    public MdnsResponse getCachedService(@NonNull String serviceName, @NonNull CacheKey cacheKey) {
         ensureRunningOnHandlerThread(mHandler);
-        final List<MdnsResponse> responses =
-                mCachedServices.get(new CacheKey(serviceType, socketKey));
+        final List<MdnsResponse> responses = mCachedServices.get(cacheKey);
         if (responses == null) {
             return null;
         }
@@ -137,15 +140,13 @@
     /**
      * Add or update a service.
      *
-     * @param serviceType the service type.
-     * @param socketKey the target socket
+     * @param cacheKey the target CacheKey.
      * @param response the response of the discovered service.
      */
-    public void addOrUpdateService(@NonNull String serviceType, @NonNull SocketKey socketKey,
-            @NonNull MdnsResponse response) {
+    public void addOrUpdateService(@NonNull CacheKey cacheKey, @NonNull MdnsResponse response) {
         ensureRunningOnHandlerThread(mHandler);
         final List<MdnsResponse> responses = mCachedServices.computeIfAbsent(
-                new CacheKey(serviceType, socketKey), key -> new ArrayList<>());
+                cacheKey, key -> new ArrayList<>());
         // Remove existing service if present.
         final MdnsResponse existing =
                 findMatchedResponse(responses, response.getServiceInstanceName());
@@ -157,15 +158,12 @@
      * Remove a service which matches the given service name, type and socket.
      *
      * @param serviceName the target service name.
-     * @param serviceType the target service type.
-     * @param socketKey the target socket.
+     * @param cacheKey the target CacheKey.
      */
     @Nullable
-    public MdnsResponse removeService(@NonNull String serviceName, @NonNull String serviceType,
-            @NonNull SocketKey socketKey) {
+    public MdnsResponse removeService(@NonNull String serviceName, @NonNull CacheKey cacheKey) {
         ensureRunningOnHandlerThread(mHandler);
-        final List<MdnsResponse> responses =
-                mCachedServices.get(new CacheKey(serviceType, socketKey));
+        final List<MdnsResponse> responses = mCachedServices.get(cacheKey);
         if (responses == null) {
             return null;
         }
@@ -180,5 +178,37 @@
         return null;
     }
 
+    /**
+     * Register a callback to listen to service expiration.
+     *
+     * <p> Registering the same callback instance twice is a no-op, since MdnsServiceTypeClient
+     * relies on this.
+     *
+     * @param cacheKey the target CacheKey.
+     * @param callback the callback that notify the service is expired.
+     */
+    public void registerServiceExpiredCallback(@NonNull CacheKey cacheKey,
+            @NonNull ServiceExpiredCallback callback) {
+        ensureRunningOnHandlerThread(mHandler);
+        mCallbacks.put(cacheKey, callback);
+    }
+
+    /**
+     * Unregister the service expired callback.
+     *
+     * @param cacheKey the CacheKey that is registered to listen service expiration before.
+     */
+    public void unregisterServiceExpiredCallback(@NonNull CacheKey cacheKey) {
+        ensureRunningOnHandlerThread(mHandler);
+        mCallbacks.remove(cacheKey);
+    }
+
+    /*** Callbacks for listening service expiration */
+    public interface ServiceExpiredCallback {
+        /*** Notify the service is expired */
+        void onServiceRecordExpired(@NonNull MdnsResponse previousResponse,
+                @Nullable MdnsResponse newResponse);
+    }
+
     // TODO: check ttl expiration for each service and notify to the clients.
 }
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsServiceRecord.java b/service-t/src/com/android/server/connectivity/mdns/MdnsServiceRecord.java
index f851b35..4d407be 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsServiceRecord.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsServiceRecord.java
@@ -18,7 +18,8 @@
 
 import android.annotation.Nullable;
 
-import com.android.internal.annotations.VisibleForTesting;
+import androidx.annotation.VisibleForTesting;
+
 import com.android.server.connectivity.mdns.util.MdnsUtils;
 
 import java.io.IOException;
@@ -27,7 +28,7 @@
 import java.util.Objects;
 
 /** An mDNS "SRV" record, which contains service information. */
-@VisibleForTesting
+@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
 public class MdnsServiceRecord extends MdnsRecord {
     public static final int PROTO_NONE = 0;
     public static final int PROTO_TCP = 1;
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 bbe8f4c..0a03186 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsServiceTypeClient.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsServiceTypeClient.java
@@ -16,6 +16,7 @@
 
 package com.android.server.connectivity.mdns;
 
+import static com.android.server.connectivity.mdns.MdnsServiceCache.ServiceExpiredCallback;
 import static com.android.server.connectivity.mdns.MdnsServiceCache.findMatchedResponse;
 import static com.android.server.connectivity.mdns.util.MdnsUtils.Clock;
 import static com.android.server.connectivity.mdns.util.MdnsUtils.ensureRunningOnHandlerThread;
@@ -71,6 +72,15 @@
      * The service caches for each socket. It should be accessed from looper thread only.
      */
     @NonNull private final MdnsServiceCache serviceCache;
+    @NonNull private final MdnsServiceCache.CacheKey cacheKey;
+    @NonNull private final ServiceExpiredCallback serviceExpiredCallback =
+            new ServiceExpiredCallback() {
+                @Override
+                public void onServiceRecordExpired(@NonNull MdnsResponse previousResponse,
+                        @Nullable MdnsResponse newResponse) {
+                    notifyRemovedServiceToListeners(previousResponse, "Service record expired");
+                }
+            };
     private final ArrayMap<MdnsServiceBrowserListener, MdnsSearchOptions> listeners =
             new ArrayMap<>();
     private final boolean removeServiceAfterTtlExpires =
@@ -225,6 +235,16 @@
         this.dependencies = dependencies;
         this.serviceCache = serviceCache;
         this.mdnsQueryScheduler = new MdnsQueryScheduler();
+        this.cacheKey = new MdnsServiceCache.CacheKey(serviceType, socketKey);
+    }
+
+    /**
+     * Do the cleanup of the MdnsServiceTypeClient
+     */
+    private void shutDown() {
+        removeScheduledTask();
+        mdnsQueryScheduler.cancelScheduledRun();
+        serviceCache.unregisterServiceExpiredCallback(cacheKey);
     }
 
     private static MdnsServiceInfo buildMdnsServiceInfoFromResponse(
@@ -293,7 +313,7 @@
         boolean hadReply = false;
         if (listeners.put(listener, searchOptions) == null) {
             for (MdnsResponse existingResponse :
-                    serviceCache.getCachedServices(serviceType, socketKey)) {
+                    serviceCache.getCachedServices(cacheKey)) {
                 if (!responseMatchesOptions(existingResponse, searchOptions)) continue;
                 final MdnsServiceInfo info =
                         buildMdnsServiceInfoFromResponse(existingResponse, serviceTypeLabels);
@@ -341,6 +361,8 @@
                     servicesToResolve.size() < listeners.size() /* sendDiscoveryQueries */);
             executor.submit(queryTask);
         }
+
+        serviceCache.registerServiceExpiredCallback(cacheKey, serviceExpiredCallback);
     }
 
     /**
@@ -390,8 +412,7 @@
             return listeners.isEmpty();
         }
         if (listeners.isEmpty()) {
-            removeScheduledTask();
-            mdnsQueryScheduler.cancelScheduledRun();
+            shutDown();
         }
         return listeners.isEmpty();
     }
@@ -404,8 +425,7 @@
         ensureRunningOnHandlerThread(handler);
         // Augment the list of current known responses, and generated responses for resolve
         // requests if there is no known response
-        final List<MdnsResponse> cachedList =
-                serviceCache.getCachedServices(serviceType, socketKey);
+        final List<MdnsResponse> cachedList = serviceCache.getCachedServices(cacheKey);
         final List<MdnsResponse> currentList = new ArrayList<>(cachedList);
         List<MdnsResponse> additionalResponses = makeResponsesForResolve(socketKey);
         for (MdnsResponse additionalResponse : additionalResponses) {
@@ -432,7 +452,7 @@
             } else if (findMatchedResponse(cachedList, serviceInstanceName) != null) {
                 // If the response is not modified and already in the cache. The cache will
                 // need to be updated to refresh the last receipt time.
-                serviceCache.addOrUpdateService(serviceType, socketKey, response);
+                serviceCache.addOrUpdateService(cacheKey, response);
             }
         }
         if (dependencies.hasMessages(handler, EVENT_START_QUERYTASK)) {
@@ -458,44 +478,50 @@
         }
     }
 
-    /** Notify all services are removed because the socket is destroyed. */
-    public void notifySocketDestroyed() {
-        ensureRunningOnHandlerThread(handler);
-        for (MdnsResponse response : serviceCache.getCachedServices(serviceType, socketKey)) {
-            final String name = response.getServiceInstanceName();
-            if (name == null) continue;
-            for (int i = 0; i < listeners.size(); i++) {
-                if (!responseMatchesOptions(response, listeners.valueAt(i))) continue;
-                final MdnsServiceBrowserListener listener = listeners.keyAt(i);
-                final MdnsServiceInfo serviceInfo =
-                        buildMdnsServiceInfoFromResponse(response, serviceTypeLabels);
+    private void notifyRemovedServiceToListeners(@NonNull MdnsResponse response,
+            @NonNull String message) {
+        for (int i = 0; i < listeners.size(); i++) {
+            if (!responseMatchesOptions(response, listeners.valueAt(i))) continue;
+            final MdnsServiceBrowserListener listener = listeners.keyAt(i);
+            if (response.getServiceInstanceName() != null) {
+                final MdnsServiceInfo serviceInfo = buildMdnsServiceInfoFromResponse(
+                        response, serviceTypeLabels);
                 if (response.isComplete()) {
-                    sharedLog.log("Socket destroyed. onServiceRemoved: " + name);
+                    sharedLog.log(message + ". onServiceRemoved: " + serviceInfo);
                     listener.onServiceRemoved(serviceInfo);
                 }
-                sharedLog.log("Socket destroyed. onServiceNameRemoved: " + name);
+                sharedLog.log(message + ". onServiceNameRemoved: " + serviceInfo);
                 listener.onServiceNameRemoved(serviceInfo);
             }
         }
-        removeScheduledTask();
-        mdnsQueryScheduler.cancelScheduledRun();
+    }
+
+    /** Notify all services are removed because the socket is destroyed. */
+    public void notifySocketDestroyed() {
+        ensureRunningOnHandlerThread(handler);
+        for (MdnsResponse response : serviceCache.getCachedServices(cacheKey)) {
+            final String name = response.getServiceInstanceName();
+            if (name == null) continue;
+            notifyRemovedServiceToListeners(response, "Socket destroyed");
+        }
+        shutDown();
     }
 
     private void onResponseModified(@NonNull MdnsResponse response) {
         final String serviceInstanceName = response.getServiceInstanceName();
         final MdnsResponse currentResponse =
-                serviceCache.getCachedService(serviceInstanceName, serviceType, socketKey);
+                serviceCache.getCachedService(serviceInstanceName, cacheKey);
 
         boolean newServiceFound = false;
         boolean serviceBecomesComplete = false;
         if (currentResponse == null) {
             newServiceFound = true;
             if (serviceInstanceName != null) {
-                serviceCache.addOrUpdateService(serviceType, socketKey, response);
+                serviceCache.addOrUpdateService(cacheKey, response);
             }
         } else {
             boolean before = currentResponse.isComplete();
-            serviceCache.addOrUpdateService(serviceType, socketKey, response);
+            serviceCache.addOrUpdateService(cacheKey, response);
             boolean after = response.isComplete();
             serviceBecomesComplete = !before && after;
         }
@@ -529,22 +555,11 @@
 
     private void onGoodbyeReceived(@Nullable String serviceInstanceName) {
         final MdnsResponse response =
-                serviceCache.removeService(serviceInstanceName, serviceType, socketKey);
+                serviceCache.removeService(serviceInstanceName, cacheKey);
         if (response == null) {
             return;
         }
-        for (int i = 0; i < listeners.size(); i++) {
-            if (!responseMatchesOptions(response, listeners.valueAt(i))) continue;
-            final MdnsServiceBrowserListener listener = listeners.keyAt(i);
-            final MdnsServiceInfo serviceInfo =
-                    buildMdnsServiceInfoFromResponse(response, serviceTypeLabels);
-            if (response.isComplete()) {
-                sharedLog.log("onServiceRemoved: " + serviceInfo);
-                listener.onServiceRemoved(serviceInfo);
-            }
-            sharedLog.log("onServiceNameRemoved: " + serviceInfo);
-            listener.onServiceNameRemoved(serviceInfo);
-        }
+        notifyRemovedServiceToListeners(response, "Goodbye received");
     }
 
     private boolean shouldRemoveServiceAfterTtlExpires() {
@@ -567,7 +582,7 @@
                 continue;
             }
             MdnsResponse knownResponse =
-                    serviceCache.getCachedService(resolveName, serviceType, socketKey);
+                    serviceCache.getCachedService(resolveName, cacheKey);
             if (knownResponse == null) {
                 final ArrayList<String> instanceFullName = new ArrayList<>(
                         serviceTypeLabels.length + 1);
@@ -585,36 +600,18 @@
     private void tryRemoveServiceAfterTtlExpires() {
         if (!shouldRemoveServiceAfterTtlExpires()) return;
 
-        Iterator<MdnsResponse> iter =
-                serviceCache.getCachedServices(serviceType, socketKey).iterator();
+        final Iterator<MdnsResponse> iter = serviceCache.getCachedServices(cacheKey).iterator();
         while (iter.hasNext()) {
             MdnsResponse existingResponse = iter.next();
-            final String serviceInstanceName = existingResponse.getServiceInstanceName();
             if (existingResponse.hasServiceRecord()
                     && existingResponse.getServiceRecord()
                     .getRemainingTTL(clock.elapsedRealtime()) == 0) {
-                serviceCache.removeService(serviceInstanceName, serviceType, socketKey);
-                for (int i = 0; i < listeners.size(); i++) {
-                    if (!responseMatchesOptions(existingResponse, listeners.valueAt(i))) {
-                        continue;
-                    }
-                    final MdnsServiceBrowserListener listener = listeners.keyAt(i);
-                    if (serviceInstanceName != null) {
-                        final MdnsServiceInfo serviceInfo = buildMdnsServiceInfoFromResponse(
-                                existingResponse, serviceTypeLabels);
-                        if (existingResponse.isComplete()) {
-                            sharedLog.log("TTL expired. onServiceRemoved: " + serviceInfo);
-                            listener.onServiceRemoved(serviceInfo);
-                        }
-                        sharedLog.log("TTL expired. onServiceNameRemoved: " + serviceInfo);
-                        listener.onServiceNameRemoved(serviceInfo);
-                    }
-                }
+                serviceCache.removeService(existingResponse.getServiceInstanceName(), cacheKey);
+                notifyRemovedServiceToListeners(existingResponse, "TTL expired");
             }
         }
     }
 
-
     private static class QuerySentArguments {
         private final int transactionId;
         private final List<String> subTypes = new ArrayList<>();
@@ -672,7 +669,7 @@
 
     private long getMinRemainingTtl(long now) {
         long minRemainingTtl = Long.MAX_VALUE;
-        for (MdnsResponse response : serviceCache.getCachedServices(serviceType, socketKey)) {
+        for (MdnsResponse response : serviceCache.getCachedServices(cacheKey)) {
             if (!response.isComplete()) {
                 continue;
             }
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsTextRecord.java b/service-t/src/com/android/server/connectivity/mdns/MdnsTextRecord.java
index 4149dbe..cf6c8ac 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsTextRecord.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsTextRecord.java
@@ -18,7 +18,8 @@
 
 import android.annotation.Nullable;
 
-import com.android.internal.annotations.VisibleForTesting;
+import androidx.annotation.VisibleForTesting;
+
 import com.android.server.connectivity.mdns.MdnsServiceInfo.TextEntry;
 
 import java.io.IOException;
@@ -28,7 +29,7 @@
 import java.util.Objects;
 
 /** An mDNS "TXT" record, which contains a list of {@link TextEntry}. */
-@VisibleForTesting
+@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
 public class MdnsTextRecord extends MdnsRecord {
     private List<TextEntry> entries;
 
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 0dcc560..4d79f9d 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
@@ -35,6 +35,7 @@
 import java.nio.charset.Charset;
 import java.nio.charset.CharsetEncoder;
 import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
 import java.util.HashSet;
 import java.util.Set;
 
@@ -189,7 +190,7 @@
         // TODO: support packets over size (send in multiple packets with TC bit set)
         final MdnsPacketWriter writer = new MdnsPacketWriter(packetCreationBuffer);
 
-        writer.writeUInt16(0); // Transaction ID (advertisement: 0)
+        writer.writeUInt16(packet.transactionId); // Transaction ID (advertisement: 0)
         writer.writeUInt16(packet.flags); // Response, authoritative (rfc6762 18.4)
         writer.writeUInt16(packet.questions.size()); // questions count
         writer.writeUInt16(packet.answers.size()); // answers count
@@ -211,9 +212,7 @@
         }
 
         final int len = writer.getWritePosition();
-        final byte[] outBuffer = new byte[len];
-        System.arraycopy(packetCreationBuffer, 0, outBuffer, 0, len);
-        return outBuffer;
+        return Arrays.copyOfRange(packetCreationBuffer, 0, len);
     }
 
     /**
diff --git a/service-t/src/com/android/server/ethernet/EthernetTracker.java b/service-t/src/com/android/server/ethernet/EthernetTracker.java
index 48e86d8..01b8de7 100644
--- a/service-t/src/com/android/server/ethernet/EthernetTracker.java
+++ b/service-t/src/com/android/server/ethernet/EthernetTracker.java
@@ -48,6 +48,7 @@
 
 import com.android.internal.annotations.VisibleForTesting;
 import com.android.internal.util.IndentingPrintWriter;
+import com.android.modules.utils.build.SdkLevel;
 import com.android.net.module.util.NetdUtils;
 import com.android.net.module.util.PermissionUtils;
 import com.android.net.module.util.SharedLog;
@@ -237,7 +238,18 @@
         mDeps = deps;
 
         // Interface match regex.
-        mIfaceMatch = mDeps.getInterfaceRegexFromResource(mContext);
+        String ifaceMatchRegex = mDeps.getInterfaceRegexFromResource(mContext);
+        // "*" is a magic string to indicate "pick the default".
+        if (ifaceMatchRegex.equals("*")) {
+            if (SdkLevel.isAtLeastU()) {
+                // On U+, include both usb%d and eth%d interfaces.
+                ifaceMatchRegex = "(usb|eth)\\d+";
+            } else {
+                // On T, include only eth%d interfaces.
+                ifaceMatchRegex = "eth\\d+";
+            }
+        }
+        mIfaceMatch = ifaceMatchRegex;
 
         // Read default Ethernet interface configuration from resources
         final String[] interfaceConfigs = mDeps.getInterfaceConfigFromResource(context);
diff --git a/service-t/src/com/android/server/net/NetworkStatsEventLogger.java b/service-t/src/com/android/server/net/NetworkStatsEventLogger.java
new file mode 100644
index 0000000..679837a
--- /dev/null
+++ b/service-t/src/com/android/server/net/NetworkStatsEventLogger.java
@@ -0,0 +1,152 @@
+/*
+ * 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;
+
+import static com.android.internal.annotations.VisibleForTesting.Visibility.PRIVATE;
+
+import android.annotation.IntDef;
+import android.annotation.NonNull;
+import android.util.IndentingPrintWriter;
+import android.util.LocalLog;
+
+import com.android.internal.annotations.VisibleForTesting;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+/**
+ * Helper class for NetworkStatsService to log events.
+ *
+ * @hide
+ */
+public class NetworkStatsEventLogger {
+    static final int POLL_REASON_DUMPSYS = 0;
+    static final int POLL_REASON_FORCE_UPDATE = 1;
+    static final int POLL_REASON_GLOBAL_ALERT = 2;
+    static final int POLL_REASON_NETWORK_STATUS_CHANGED = 3;
+    static final int POLL_REASON_OPEN_SESSION = 4;
+    static final int POLL_REASON_PERIODIC = 5;
+    static final int POLL_REASON_RAT_CHANGED = 6;
+    static final int POLL_REASON_REG_CALLBACK = 7;
+    static final int POLL_REASON_REMOVE_UIDS = 8;
+    static final int POLL_REASON_UPSTREAM_CHANGED = 9;
+
+    @Retention(RetentionPolicy.SOURCE)
+    @IntDef(prefix = { "POLL_REASON_" }, value = {
+            POLL_REASON_DUMPSYS,
+            POLL_REASON_FORCE_UPDATE,
+            POLL_REASON_GLOBAL_ALERT,
+            POLL_REASON_NETWORK_STATUS_CHANGED,
+            POLL_REASON_OPEN_SESSION,
+            POLL_REASON_PERIODIC,
+            POLL_REASON_RAT_CHANGED,
+            POLL_REASON_REMOVE_UIDS,
+            POLL_REASON_REG_CALLBACK,
+            POLL_REASON_UPSTREAM_CHANGED
+    })
+    public @interface PollReason {
+    }
+    static final int MAX_POLL_REASON = POLL_REASON_UPSTREAM_CHANGED;
+
+    @VisibleForTesting(visibility = PRIVATE)
+    public static final int MAX_EVENTS_LOGS = 50;
+    private final LocalLog mEventChanges = new LocalLog(MAX_EVENTS_LOGS);
+    private final int[] mPollEventCounts = new int[MAX_POLL_REASON + 1];
+
+    /**
+     * Log a poll event.
+     *
+     * @param flags Flags used when polling. See NetworkStatsService#FLAG_PERSIST_*.
+     * @param event The event of polling to be logged.
+     */
+    public void logPollEvent(int flags, @NonNull PollEvent event) {
+        mEventChanges.log("Poll(flags=" + flags + ", " + event + ")");
+        mPollEventCounts[event.reason]++;
+    }
+
+    /**
+     * Print poll counts per reason into the given stream.
+     */
+    @VisibleForTesting(visibility = PRIVATE)
+    public void dumpPollCountsPerReason(@NonNull IndentingPrintWriter pw) {
+        pw.println("Poll counts per reason:");
+        pw.increaseIndent();
+        for (int i = 0; i <= MAX_POLL_REASON; i++) {
+            pw.println(PollEvent.pollReasonNameOf(i) + ": " + mPollEventCounts[i]);
+        }
+        pw.decreaseIndent();
+        pw.println();
+    }
+
+    /**
+     * Print recent poll events into the given stream.
+     */
+    @VisibleForTesting(visibility = PRIVATE)
+    public void dumpRecentPollEvents(@NonNull IndentingPrintWriter pw) {
+        pw.println("Recent poll events:");
+        pw.increaseIndent();
+        mEventChanges.reverseDump(pw);
+        pw.decreaseIndent();
+        pw.println();
+    }
+
+    /**
+     * Print the object's state into the given stream.
+     */
+    public void dump(@NonNull IndentingPrintWriter pw) {
+        dumpPollCountsPerReason(pw);
+        dumpRecentPollEvents(pw);
+    }
+
+    public static class PollEvent {
+        public final int reason;
+
+        public PollEvent(@PollReason int reason) {
+            if (reason < 0 || reason > MAX_POLL_REASON) {
+                throw new IllegalArgumentException("Unsupported poll reason: " + reason);
+            }
+            this.reason = reason;
+        }
+
+        @Override
+        public String toString() {
+            return "PollEvent{" + "reason=" + pollReasonNameOf(reason) + "}";
+        }
+
+        /**
+         * Get the name of the given reason.
+         *
+         * If the reason does not have a String representation, returns its integer representation.
+         */
+        @NonNull
+        public static String pollReasonNameOf(@PollReason int reason) {
+            switch (reason) {
+                case POLL_REASON_DUMPSYS:                   return "DUMPSYS";
+                case POLL_REASON_FORCE_UPDATE:              return "FORCE_UPDATE";
+                case POLL_REASON_GLOBAL_ALERT:              return "GLOBAL_ALERT";
+                case POLL_REASON_NETWORK_STATUS_CHANGED:    return "NETWORK_STATUS_CHANGED";
+                case POLL_REASON_OPEN_SESSION:              return "OPEN_SESSION";
+                case POLL_REASON_PERIODIC:                  return "PERIODIC";
+                case POLL_REASON_RAT_CHANGED:               return "RAT_CHANGED";
+                case POLL_REASON_REMOVE_UIDS:               return "REMOVE_UIDS";
+                case POLL_REASON_REG_CALLBACK:              return "REG_CALLBACK";
+                case POLL_REASON_UPSTREAM_CHANGED:          return "UPSTREAM_CHANGED";
+                default:                                    return Integer.toString(reason);
+            }
+        }
+    }
+}
diff --git a/service-t/src/com/android/server/net/NetworkStatsObservers.java b/service-t/src/com/android/server/net/NetworkStatsObservers.java
index 1cd670a..21cf351 100644
--- a/service-t/src/com/android/server/net/NetworkStatsObservers.java
+++ b/service-t/src/com/android/server/net/NetworkStatsObservers.java
@@ -142,6 +142,11 @@
 
     @VisibleForTesting
     protected Looper getHandlerLooperLocked() {
+        // TODO: Currently, callbacks are dispatched on this thread if the caller register
+        //  callback without supplying a Handler. To ensure that the service handler thread
+        //  is not blocked by client code, the observers must create their own thread. Once
+        //  all callbacks are dispatched outside of the handler thread, the service handler
+        //  thread can be used here.
         HandlerThread handlerThread = new HandlerThread(TAG);
         handlerThread.start();
         return handlerThread.getLooper();
diff --git a/service-t/src/com/android/server/net/NetworkStatsService.java b/service-t/src/com/android/server/net/NetworkStatsService.java
index 25e59d5..46afd31 100644
--- a/service-t/src/com/android/server/net/NetworkStatsService.java
+++ b/service-t/src/com/android/server/net/NetworkStatsService.java
@@ -66,6 +66,17 @@
 import static com.android.internal.annotations.VisibleForTesting.Visibility.PRIVATE;
 import static com.android.net.module.util.NetworkCapabilitiesUtils.getDisplayTransport;
 import static com.android.net.module.util.NetworkStatsUtils.LIMIT_GLOBAL_ALERT;
+import static com.android.server.net.NetworkStatsEventLogger.POLL_REASON_PERIODIC;
+import static com.android.server.net.NetworkStatsEventLogger.POLL_REASON_DUMPSYS;
+import static com.android.server.net.NetworkStatsEventLogger.POLL_REASON_FORCE_UPDATE;
+import static com.android.server.net.NetworkStatsEventLogger.POLL_REASON_GLOBAL_ALERT;
+import static com.android.server.net.NetworkStatsEventLogger.POLL_REASON_NETWORK_STATUS_CHANGED;
+import static com.android.server.net.NetworkStatsEventLogger.POLL_REASON_OPEN_SESSION;
+import static com.android.server.net.NetworkStatsEventLogger.POLL_REASON_RAT_CHANGED;
+import static com.android.server.net.NetworkStatsEventLogger.POLL_REASON_REG_CALLBACK;
+import static com.android.server.net.NetworkStatsEventLogger.POLL_REASON_REMOVE_UIDS;
+import static com.android.server.net.NetworkStatsEventLogger.POLL_REASON_UPSTREAM_CHANGED;
+import static com.android.server.net.NetworkStatsEventLogger.PollEvent;
 
 import android.annotation.NonNull;
 import android.annotation.Nullable;
@@ -281,6 +292,8 @@
     static final String NETSTATS_IMPORT_ATTEMPTS_COUNTER_NAME = "import.attempts";
     static final String NETSTATS_IMPORT_SUCCESSES_COUNTER_NAME = "import.successes";
     static final String NETSTATS_IMPORT_FALLBACKS_COUNTER_NAME = "import.fallbacks";
+    static final String CONFIG_ENABLE_NETWORK_STATS_EVENT_LOGGER =
+            "enable_network_stats_event_logger";
 
     private final Context mContext;
     private final NetworkStatsFactory mStatsFactory;
@@ -441,6 +454,7 @@
      * Map from key {@code OpenSessionKey} to count of opened sessions. This is for recording
      * the caller of open session and it is only for debugging.
      */
+    // TODO: Move to NetworkStatsEventLogger to centralize event logging.
     @GuardedBy("mOpenSessionCallsLock")
     private final HashMap<OpenSessionKey, Integer> mOpenSessionCallsPerCaller = new HashMap<>();
 
@@ -513,19 +527,21 @@
         public void handleMessage(Message msg) {
             switch (msg.what) {
                 case MSG_PERFORM_POLL: {
-                    performPoll(FLAG_PERSIST_ALL);
+                    performPoll(FLAG_PERSIST_ALL, maybeCreatePollEvent((int) msg.obj));
                     break;
                 }
                 case MSG_NOTIFY_NETWORK_STATUS: {
-                    // If no cached states, ignore.
-                    if (mLastNetworkStateSnapshots == null) break;
-                    // TODO (b/181642673): Protect mDefaultNetworks from concurrent accessing.
-                    handleNotifyNetworkStatus(
-                            mDefaultNetworks, mLastNetworkStateSnapshots, mActiveIface);
+                    synchronized (mStatsLock) {
+                        // If no cached states, ignore.
+                        if (mLastNetworkStateSnapshots == null) break;
+                        handleNotifyNetworkStatus(mDefaultNetworks, mLastNetworkStateSnapshots,
+                                mActiveIface, maybeCreatePollEvent((int) msg.obj));
+                    }
                     break;
                 }
                 case MSG_PERFORM_POLL_REGISTER_ALERT: {
-                    performPoll(FLAG_PERSIST_NETWORK);
+                    performPoll(FLAG_PERSIST_NETWORK,
+                            maybeCreatePollEvent(POLL_REASON_GLOBAL_ALERT));
                     registerGlobalAlert();
                     break;
                 }
@@ -611,6 +627,13 @@
         mStatsMapB = mDeps.getStatsMapB();
         mAppUidStatsMap = mDeps.getAppUidStatsMap();
         mIfaceStatsMap = mDeps.getIfaceStatsMap();
+        // To prevent any possible races, the flag is not allowed to change until rebooting.
+        mSupportEventLogger = mDeps.supportEventLogger(mContext);
+        if (mSupportEventLogger) {
+            mEventLogger = new NetworkStatsEventLogger();
+        } else {
+            mEventLogger = null;
+        }
 
         // TODO: Remove bpfNetMaps creation and always start SkDestroyListener
         // Following code is for the experiment to verify the SkDestroyListener refactoring. Based
@@ -839,6 +862,14 @@
                 IBpfMap<CookieTagMapKey, CookieTagMapValue> cookieTagMap, Handler handler) {
             return new SkDestroyListener(cookieTagMap, handler, new SharedLog(TAG));
         }
+
+        /**
+         * Get whether event logger feature is supported.
+         */
+        public boolean supportEventLogger(Context ctx) {
+            return DeviceConfigUtils.isTetheringFeatureNotChickenedOut(
+                    ctx, CONFIG_ENABLE_NETWORK_STATS_EVENT_LOGGER);
+        }
     }
 
     /**
@@ -1431,7 +1462,7 @@
                 | NetworkStatsManager.FLAG_POLL_FORCE)) != 0) {
             final long ident = Binder.clearCallingIdentity();
             try {
-                performPoll(FLAG_PERSIST_ALL);
+                performPoll(FLAG_PERSIST_ALL, maybeCreatePollEvent(POLL_REASON_OPEN_SESSION));
             } finally {
                 Binder.restoreCallingIdentity(ident);
             }
@@ -1827,7 +1858,8 @@
 
         final long token = Binder.clearCallingIdentity();
         try {
-            handleNotifyNetworkStatus(defaultNetworks, networkStates, activeIface);
+            handleNotifyNetworkStatus(defaultNetworks, networkStates, activeIface,
+                    maybeCreatePollEvent(POLL_REASON_NETWORK_STATUS_CHANGED));
         } finally {
             Binder.restoreCallingIdentity(token);
         }
@@ -1844,7 +1876,8 @@
 
         final long token = Binder.clearCallingIdentity();
         try {
-            performPoll(FLAG_PERSIST_ALL);
+            // TODO: Log callstack for system server callers.
+            performPoll(FLAG_PERSIST_ALL, maybeCreatePollEvent(POLL_REASON_FORCE_UPDATE));
         } finally {
             Binder.restoreCallingIdentity(token);
         }
@@ -1901,7 +1934,8 @@
         }
 
         // Create baseline stats
-        mHandler.sendMessage(mHandler.obtainMessage(MSG_PERFORM_POLL));
+        mHandler.sendMessage(mHandler.obtainMessage(MSG_PERFORM_POLL,
+                POLL_REASON_REG_CALLBACK));
 
         return normalizedRequest;
    }
@@ -1998,7 +2032,8 @@
             new TetheringManager.TetheringEventCallback() {
                 @Override
                 public void onUpstreamChanged(@Nullable Network network) {
-                    performPoll(FLAG_PERSIST_NETWORK);
+                    performPoll(FLAG_PERSIST_NETWORK,
+                            maybeCreatePollEvent(POLL_REASON_UPSTREAM_CHANGED));
                 }
             };
 
@@ -2007,7 +2042,7 @@
         public void onReceive(Context context, Intent intent) {
             // on background handler thread, and verified UPDATE_DEVICE_STATS
             // permission above.
-            performPoll(FLAG_PERSIST_ALL);
+            performPoll(FLAG_PERSIST_ALL, maybeCreatePollEvent(POLL_REASON_PERIODIC));
 
             // verify that we're watching global alert
             registerGlobalAlert();
@@ -2071,19 +2106,20 @@
     public void handleOnCollapsedRatTypeChanged() {
         // Protect service from frequently updating. Remove pending messages if any.
         mHandler.removeMessages(MSG_NOTIFY_NETWORK_STATUS);
-        mHandler.sendMessageDelayed(
-                mHandler.obtainMessage(MSG_NOTIFY_NETWORK_STATUS), mSettings.getPollDelay());
+        mHandler.sendMessageDelayed(mHandler.obtainMessage(MSG_NOTIFY_NETWORK_STATUS,
+                        POLL_REASON_RAT_CHANGED), mSettings.getPollDelay());
     }
 
     private void handleNotifyNetworkStatus(
             Network[] defaultNetworks,
             NetworkStateSnapshot[] snapshots,
-            String activeIface) {
+            String activeIface,
+            @Nullable PollEvent event) {
         synchronized (mStatsLock) {
             mWakeLock.acquire();
             try {
                 mActiveIface = activeIface;
-                handleNotifyNetworkStatusLocked(defaultNetworks, snapshots);
+                handleNotifyNetworkStatusLocked(defaultNetworks, snapshots, event);
             } finally {
                 mWakeLock.release();
             }
@@ -2097,7 +2133,7 @@
      */
     @GuardedBy("mStatsLock")
     private void handleNotifyNetworkStatusLocked(@NonNull Network[] defaultNetworks,
-            @NonNull NetworkStateSnapshot[] snapshots) {
+            @NonNull NetworkStateSnapshot[] snapshots, @Nullable PollEvent event) {
         if (!mSystemReady) return;
         if (LOGV) Log.v(TAG, "handleNotifyNetworkStatusLocked()");
 
@@ -2107,7 +2143,7 @@
 
         // poll, but only persist network stats to keep codepath fast. UID stats
         // will be persisted during next alarm poll event.
-        performPollLocked(FLAG_PERSIST_NETWORK);
+        performPollLocked(FLAG_PERSIST_NETWORK, event);
 
         // Rebuild active interfaces based on connected networks
         mActiveIfaces.clear();
@@ -2324,12 +2360,12 @@
         }
     }
 
-    private void performPoll(int flags) {
+    private void performPoll(int flags, @Nullable PollEvent event) {
         synchronized (mStatsLock) {
             mWakeLock.acquire();
 
             try {
-                performPollLocked(flags);
+                performPollLocked(flags, event);
             } finally {
                 mWakeLock.release();
             }
@@ -2341,11 +2377,15 @@
      * {@link NetworkStatsHistory}.
      */
     @GuardedBy("mStatsLock")
-    private void performPollLocked(int flags) {
+    private void performPollLocked(int flags, @Nullable PollEvent event) {
         if (!mSystemReady) return;
         if (LOGV) Log.v(TAG, "performPollLocked(flags=0x" + Integer.toHexString(flags) + ")");
         Trace.traceBegin(TRACE_TAG_NETWORK, "performPollLocked");
 
+        if (mSupportEventLogger) {
+            mEventLogger.logPollEvent(flags, event);
+        }
+
         final boolean persistNetwork = (flags & FLAG_PERSIST_NETWORK) != 0;
         final boolean persistUid = (flags & FLAG_PERSIST_UID) != 0;
         final boolean persistForce = (flags & FLAG_PERSIST_FORCE) != 0;
@@ -2545,7 +2585,7 @@
         if (LOGV) Log.v(TAG, "removeUidsLocked() for UIDs " + Arrays.toString(uids));
 
         // Perform one last poll before removing
-        performPollLocked(FLAG_PERSIST_ALL);
+        performPollLocked(FLAG_PERSIST_ALL, maybeCreatePollEvent(POLL_REASON_REMOVE_UIDS));
 
         mUidRecorder.removeUidsLocked(uids);
         mUidTagRecorder.removeUidsLocked(uids);
@@ -2628,7 +2668,8 @@
             }
 
             if (poll) {
-                performPollLocked(FLAG_PERSIST_ALL | FLAG_PERSIST_FORCE);
+                performPollLocked(FLAG_PERSIST_ALL | FLAG_PERSIST_FORCE,
+                        maybeCreatePollEvent(POLL_REASON_DUMPSYS));
                 pw.println("Forced poll");
                 return;
             }
@@ -2688,6 +2729,7 @@
                     pw.println("(failed to dump platform legacy stats import counters)");
                 }
             }
+            pw.println(CONFIG_ENABLE_NETWORK_STATS_EVENT_LOGGER + ": " + mSupportEventLogger);
 
             pw.decreaseIndent();
 
@@ -2745,6 +2787,10 @@
             pw.decreaseIndent();
             pw.println();
 
+            if (mSupportEventLogger) {
+                mEventLogger.dump(pw);
+            }
+
             pw.println("Stats Providers:");
             pw.increaseIndent();
             invokeForAllStatsProviderCallbacks((cb) -> {
@@ -3214,6 +3260,22 @@
         }
     }
 
+    private final boolean mSupportEventLogger;
+    @GuardedBy("mStatsLock")
+    @Nullable
+    private final NetworkStatsEventLogger mEventLogger;
+
+    /**
+     * Create a PollEvent instance if the feature is enabled.
+     */
+    @Nullable
+    public PollEvent maybeCreatePollEvent(@NetworkStatsEventLogger.PollReason int reason) {
+        if (mSupportEventLogger) {
+            return new PollEvent(reason);
+        }
+        return null;
+    }
+
     private class DropBoxNonMonotonicObserver implements NonMonotonicObserver<String> {
         @Override
         public void foundNonMonotonic(NetworkStats left, int leftIndex, NetworkStats right,
diff --git a/service/Android.bp b/service/Android.bp
index 8e59e86..7def200 100644
--- a/service/Android.bp
+++ b/service/Android.bp
@@ -185,10 +185,9 @@
         "androidx.annotation_annotation",
         "connectivity-net-module-utils-bpf",
         "connectivity_native_aidl_interface-lateststable-java",
-        "dnsresolver_aidl_interface-V11-java",
+        "dnsresolver_aidl_interface-V12-java",
         "modules-utils-shell-command-handler",
         "net-utils-device-common",
-        "net-utils-device-common-bpf",
         "net-utils-device-common-ip",
         "net-utils-device-common-netlink",
         "net-utils-services-common",
diff --git a/service/ServiceConnectivityResources/res/values-as/strings.xml b/service/ServiceConnectivityResources/res/values-as/strings.xml
index e753cb3..7e4dd42 100644
--- a/service/ServiceConnectivityResources/res/values-as/strings.xml
+++ b/service/ServiceConnectivityResources/res/values-as/strings.xml
@@ -18,7 +18,7 @@
 <resources xmlns:android="http://schemas.android.com/apk/res/android"
     xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
     <string name="connectivityResourcesAppLabel" msgid="2476261877900882974">"ছিষ্টেম সংযোগৰ উৎস"</string>
-    <string name="wifi_available_sign_in" msgid="8041178343789805553">"ৱাই-ফাই নেটৱৰ্কত ছাইন ইন কৰক"</string>
+    <string name="wifi_available_sign_in" msgid="8041178343789805553">"Wi-Fi নেটৱৰ্কত ছাইন ইন কৰক"</string>
     <string name="network_available_sign_in" msgid="2622520134876355561">"নেটৱৰ্কত ছাইন ইন কৰক"</string>
     <!-- no translation found for network_available_sign_in_detailed (8439369644697866359) -->
     <skip />
diff --git a/service/ServiceConnectivityResources/res/values/config.xml b/service/ServiceConnectivityResources/res/values/config.xml
index f30abc6..045d707f 100644
--- a/service/ServiceConnectivityResources/res/values/config.xml
+++ b/service/ServiceConnectivityResources/res/values/config.xml
@@ -194,8 +194,11 @@
         -->
     </string-array>
 
-    <!-- Regex of wired ethernet ifaces -->
-    <string translatable="false" name="config_ethernet_iface_regex">eth\\d</string>
+    <!-- Regex of wired ethernet ifaces. Network interfaces that match this regex will be tracked
+         by ethernet service.
+         If set to "*", ethernet service uses "(eth|usb)\\d+" on Android U+ and eth\\d+ on
+         Android T. -->
+    <string translatable="false" name="config_ethernet_iface_regex">*</string>
 
     <!-- Ignores Wi-Fi validation failures after roam.
     If validation fails on a Wi-Fi network after a roam to a new BSSID,
diff --git a/service/lint-baseline.xml b/service/lint-baseline.xml
new file mode 100644
index 0000000..5149e6d
--- /dev/null
+++ b/service/lint-baseline.xml
@@ -0,0 +1,510 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<issues format="6" by="lint 8.0.0-dev" type="baseline" dependencies="true" variant="all" version="8.0.0-dev">
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `android.app.PendingIntent#intentFilterEquals`"
+        errorLine1="            return a.intentFilterEquals(b);"
+        errorLine2="                     ~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service/src/com/android/server/ConnectivityService.java"
+            line="1358"
+            column="22"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `android.app.usage.NetworkStatsManager#notifyNetworkStatus`"
+        errorLine1="            mStatsManager.notifyNetworkStatus(getDefaultNetworks(),"
+        errorLine2="                          ~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service/src/com/android/server/ConnectivityService.java"
+            line="9938"
+            column="27"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `android.content.pm.ApplicationInfo#isOem`"
+        errorLine1="        return appInfo.isVendor() || appInfo.isOem() || appInfo.isProduct();"
+        errorLine2="                                             ~~~~~">
+        <location
+            file="packages/modules/Connectivity/service/src/com/android/server/connectivity/PermissionMonitor.java"
+            line="481"
+            column="46"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `android.content.pm.ApplicationInfo#isProduct`"
+        errorLine1="        return appInfo.isVendor() || appInfo.isOem() || appInfo.isProduct();"
+        errorLine2="                                                                ~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service/src/com/android/server/connectivity/PermissionMonitor.java"
+            line="481"
+            column="65"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `android.content.pm.ApplicationInfo#isVendor`"
+        errorLine1="        return appInfo.isVendor() || appInfo.isOem() || appInfo.isProduct();"
+        errorLine2="                       ~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service/src/com/android/server/connectivity/PermissionMonitor.java"
+            line="481"
+            column="24"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `android.net.NetworkPolicyManager#getMultipathPreference`"
+        errorLine1="            networkPreference = netPolicyManager.getMultipathPreference(network);"
+        errorLine2="                                                 ~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service/src/com/android/server/ConnectivityService.java"
+            line="5498"
+            column="50"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `android.net.NetworkPolicyManager#getRestrictBackgroundStatus`"
+        errorLine1="            return mPolicyManager.getRestrictBackgroundStatus(callerUid);"
+        errorLine2="                                  ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service/src/com/android/server/ConnectivityService.java"
+            line="2565"
+            column="35"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `android.net.NetworkPolicyManager#isUidNetworkingBlocked`"
+        errorLine1="            return mPolicyManager.isUidNetworkingBlocked(uid, metered);"
+        errorLine2="                                  ~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service/src/com/android/server/ConnectivityService.java"
+            line="1914"
+            column="35"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `android.net.NetworkPolicyManager#isUidRestrictedOnMeteredNetworks`"
+        errorLine1="            if (mPolicyManager.isUidRestrictedOnMeteredNetworks(uid)) {"
+        errorLine2="                               ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service/src/com/android/server/ConnectivityService.java"
+            line="7094"
+            column="32"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `android.net.NetworkPolicyManager#registerNetworkPolicyCallback`"
+        errorLine1="        mPolicyManager.registerNetworkPolicyCallback(null, mPolicyCallback);"
+        errorLine2="                       ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service/src/com/android/server/ConnectivityService.java"
+            line="1567"
+            column="24"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `android.net.NetworkStateSnapshot#getLinkProperties`"
+        errorLine1="                        snapshot.getLinkProperties(), snapshot.getNetworkCapabilities(),"
+        errorLine2="                                 ~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service/src/com/android/server/ConnectivityService.java"
+            line="2584"
+            column="34"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `android.net.NetworkStateSnapshot#getNetworkCapabilities`"
+        errorLine1="                        snapshot.getLinkProperties(), snapshot.getNetworkCapabilities(),"
+        errorLine2="                                                               ~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service/src/com/android/server/ConnectivityService.java"
+            line="2584"
+            column="64"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `android.net.NetworkStateSnapshot#getNetwork`"
+        errorLine1="                        snapshot.getNetwork(), snapshot.getSubscriberId()));"
+        errorLine2="                                 ~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service/src/com/android/server/ConnectivityService.java"
+            line="2585"
+            column="34"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `android.net.NetworkStateSnapshot#getNetwork`"
+        errorLine1="            final NetworkAgentInfo nai = getNetworkAgentInfoForNetwork(snapshot.getNetwork());"
+        errorLine2="                                                                                ~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service/src/com/android/server/ConnectivityService.java"
+            line="2581"
+            column="81"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `android.net.NetworkStateSnapshot#getSubscriberId`"
+        errorLine1="                        snapshot.getNetwork(), snapshot.getSubscriberId()));"
+        errorLine2="                                                        ~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service/src/com/android/server/ConnectivityService.java"
+            line="2585"
+            column="57"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `android.net.NetworkWatchlistManager#getWatchlistConfigHash`"
+        errorLine1="        return nwm.getWatchlistConfigHash();"
+        errorLine2="                   ~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service/src/com/android/server/ConnectivityService.java"
+            line="10060"
+            column="20"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `android.net.PacProxyManager#addPacProxyInstalledListener`"
+        errorLine1="        mPacProxyManager.addPacProxyInstalledListener("
+        errorLine2="                         ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service/src/com/android/server/connectivity/ProxyTracker.java"
+            line="111"
+            column="26"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `android.net.PacProxyManager#setCurrentProxyScriptUrl`"
+        errorLine1="                        () -&gt; mPacProxyManager.setCurrentProxyScriptUrl(proxyProperties));"
+        errorLine2="                                               ~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service/src/com/android/server/connectivity/ProxyTracker.java"
+            line="208"
+            column="48"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `android.net.PacProxyManager#setCurrentProxyScriptUrl`"
+        errorLine1="        mPacProxyManager.setCurrentProxyScriptUrl(proxyInfo);"
+        errorLine2="                         ~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service/src/com/android/server/connectivity/ProxyTracker.java"
+            line="252"
+            column="26"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `android.os.BatteryStatsManager#reportMobileRadioPowerState`"
+        errorLine1="                    bs.reportMobileRadioPowerState(isActive, NO_UID);"
+        errorLine2="                       ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service/src/com/android/server/ConnectivityService.java"
+            line="11006"
+            column="24"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `android.os.BatteryStatsManager#reportNetworkInterfaceForTransports`"
+        errorLine1="            batteryStats.reportNetworkInterfaceForTransports(iface, transportTypes);"
+        errorLine2="                         ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service/src/com/android/server/ConnectivityService.java"
+            line="1347"
+            column="26"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `android.os.BatteryStatsManager#reportWifiRadioPowerState`"
+        errorLine1="                    bs.reportWifiRadioPowerState(isActive, NO_UID);"
+        errorLine2="                       ~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service/src/com/android/server/ConnectivityService.java"
+            line="11009"
+            column="24"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `android.os.Build#isDebuggable`"
+        errorLine1="            if (Build.isDebuggable()) {"
+        errorLine2="                      ~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service/src/com/android/server/ConnectivityService.java"
+            line="9074"
+            column="23"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `android.os.Build#isDebuggable`"
+        errorLine1="        if (!Build.isDebuggable()) {"
+        errorLine2="                   ~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service/src/com/android/server/ConnectivityService.java"
+            line="5039"
+            column="20"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `android.os.SystemConfigManager#getSystemPermissionUids`"
+        errorLine1="        for (final int uid : mSystemConfigManager.getSystemPermissionUids(INTERNET)) {"
+        errorLine2="                                                  ~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service/src/com/android/server/connectivity/PermissionMonitor.java"
+            line="396"
+            column="51"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `android.os.SystemConfigManager#getSystemPermissionUids`"
+        errorLine1="        for (final int uid : mSystemConfigManager.getSystemPermissionUids(UPDATE_DEVICE_STATS)) {"
+        errorLine2="                                                  ~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service/src/com/android/server/connectivity/PermissionMonitor.java"
+            line="404"
+            column="51"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `android.os.UserHandle#getUid`"
+        errorLine1="                    final int uid = handle.getUid(appId);"
+        errorLine2="                                           ~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service/src/com/android/server/connectivity/PermissionMonitor.java"
+            line="1069"
+            column="44"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `android.system.Os#getsockoptInt`"
+        errorLine1="                tcpDetails.tos = Os.getsockoptInt(fd, IPPROTO_IP, IP_TOS);"
+        errorLine2="                                    ~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service/src/com/android/server/connectivity/TcpKeepaliveController.java"
+            line="285"
+            column="37"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `android.system.Os#getsockoptInt`"
+        errorLine1="                tcpDetails.ttl = Os.getsockoptInt(fd, IPPROTO_IP, IP_TTL);"
+        errorLine2="                                    ~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service/src/com/android/server/connectivity/TcpKeepaliveController.java"
+            line="287"
+            column="37"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `android.system.Os#getsockoptInt`"
+        errorLine1="            tcpDetails.ack = Os.getsockoptInt(fd, IPPROTO_TCP, TCP_QUEUE_SEQ);"
+        errorLine2="                                ~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service/src/com/android/server/connectivity/TcpKeepaliveController.java"
+            line="265"
+            column="33"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `android.system.Os#getsockoptInt`"
+        errorLine1="            tcpDetails.seq = Os.getsockoptInt(fd, IPPROTO_TCP, TCP_QUEUE_SEQ);"
+        errorLine2="                                ~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service/src/com/android/server/connectivity/TcpKeepaliveController.java"
+            line="262"
+            column="33"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `android.system.Os#ioctlInt`"
+        errorLine1="        final int result = Os.ioctlInt(fd, SIOCINQ);"
+        errorLine2="                              ~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service/src/com/android/server/connectivity/TcpKeepaliveController.java"
+            line="392"
+            column="31"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `android.system.Os#ioctlInt`"
+        errorLine1="        final int result = Os.ioctlInt(fd, SIOCOUTQ);"
+        errorLine2="                              ~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service/src/com/android/server/connectivity/TcpKeepaliveController.java"
+            line="402"
+            column="31"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `java.net.InetAddress#parseNumericAddress`"
+        errorLine1='            InetAddress.parseNumericAddress("::").getAddress();'
+        errorLine2="                        ~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service/src/com/android/server/connectivity/DscpPolicyValue.java"
+            line="99"
+            column="25"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `java.net.InetAddress#parseNumericAddress`"
+        errorLine1='    private static final InetAddress GOOGLE_DNS_4 = InetAddress.parseNumericAddress("8.8.8.8");'
+        errorLine2="                                                                ~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service/src/com/android/server/connectivity/ClatCoordinator.java"
+            line="89"
+            column="65"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `libcore.io.IoUtils#closeQuietly`"
+        errorLine1="                IoUtils.closeQuietly(pfd);"
+        errorLine2="                        ~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service/src/com/android/server/ConnectivityService.java"
+            line="9991"
+            column="25"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `libcore.io.IoUtils#closeQuietly`"
+        errorLine1="                IoUtils.closeQuietly(pfd);"
+        errorLine2="                        ~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service/src/com/android/server/ConnectivityService.java"
+            line="10008"
+            column="25"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `libcore.io.IoUtils#closeQuietly`"
+        errorLine1="            IoUtils.closeQuietly(mFileDescriptor);"
+        errorLine2="                    ~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service/src/com/android/server/connectivity/NetworkDiagnostics.java"
+            line="481"
+            column="21"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `new android.net.NetworkStateSnapshot`"
+        errorLine1="            return new NetworkStateSnapshot(network, new NetworkCapabilities(networkCapabilities),"
+        errorLine2="                   ~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service/src/com/android/server/connectivity/NetworkAgentInfo.java"
+            line="1269"
+            column="20"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Call requires API level 31 (current min is 30): `new android.net.UnderlyingNetworkInfo`"
+        errorLine1="        return new UnderlyingNetworkInfo(nai.networkCapabilities.getOwnerUid(),"
+        errorLine2="               ~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service/src/com/android/server/ConnectivityService.java"
+            line="6123"
+            column="16"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Class requires API level 31 (current min is 30): `android.net.NetworkPolicyManager.NetworkPolicyCallback`"
+        errorLine1="    private final NetworkPolicyCallback mPolicyCallback = new NetworkPolicyCallback() {"
+        errorLine2="                                                              ~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service/src/com/android/server/ConnectivityService.java"
+            line="2827"
+            column="63"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Class requires API level 31 (current min is 30): `android.net.NetworkPolicyManager`"
+        errorLine1="                 mContext.getSystemService(NetworkPolicyManager.class);"
+        errorLine2="                                           ~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service/src/com/android/server/ConnectivityService.java"
+            line="5493"
+            column="44"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Class requires API level 31 (current min is 30): `android.net.NetworkPolicyManager`"
+        errorLine1="        mPolicyManager = mContext.getSystemService(NetworkPolicyManager.class);"
+        errorLine2="                                                   ~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service/src/com/android/server/ConnectivityService.java"
+            line="1554"
+            column="52"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Class requires API level 31 (current min is 30): `android.net.NetworkWatchlistManager`"
+        errorLine1="        NetworkWatchlistManager nwm = mContext.getSystemService(NetworkWatchlistManager.class);"
+        errorLine2="                                                                ~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service/src/com/android/server/ConnectivityService.java"
+            line="10054"
+            column="65"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Class requires API level 31 (current min is 30): `android.net.PacProxyManager.PacProxyInstalledListener`"
+        errorLine1="    private class PacProxyInstalledListener implements PacProxyManager.PacProxyInstalledListener {"
+        errorLine2="                                                       ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service/src/com/android/server/connectivity/ProxyTracker.java"
+            line="90"
+            column="56"/>
+    </issue>
+
+    <issue
+        id="NewApi"
+        message="Class requires API level 31 (current min is 30): `android.net.PacProxyManager`"
+        errorLine1="        mPacProxyManager = context.getSystemService(PacProxyManager.class);"
+        errorLine2="                                                    ~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/service/src/com/android/server/connectivity/ProxyTracker.java"
+            line="108"
+            column="53"/>
+    </issue>
+
+</issues>
\ No newline at end of file
diff --git a/service/src/com/android/server/BpfNetMaps.java b/service/src/com/android/server/BpfNetMaps.java
index 4b24aaf..f20159c 100644
--- a/service/src/com/android/server/BpfNetMaps.java
+++ b/service/src/com/android/server/BpfNetMaps.java
@@ -19,14 +19,21 @@
 import static android.net.BpfNetMapsConstants.CONFIGURATION_MAP_PATH;
 import static android.net.BpfNetMapsConstants.COOKIE_TAG_MAP_PATH;
 import static android.net.BpfNetMapsConstants.CURRENT_STATS_MAP_CONFIGURATION_KEY;
+import static android.net.BpfNetMapsConstants.DATA_SAVER_DISABLED;
+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.IIF_MATCH;
+import static android.net.BpfNetMapsConstants.INGRESS_DISCARD_MAP_PATH;
 import static android.net.BpfNetMapsConstants.LOCKDOWN_VPN_MATCH;
 import static android.net.BpfNetMapsConstants.PENALTY_BOX_MATCH;
 import static android.net.BpfNetMapsConstants.UID_OWNER_MAP_PATH;
 import static android.net.BpfNetMapsConstants.UID_PERMISSION_MAP_PATH;
 import static android.net.BpfNetMapsConstants.UID_RULES_CONFIGURATION_KEY;
+import static android.net.BpfNetMapsUtils.PRE_T;
 import static android.net.BpfNetMapsUtils.getMatchByFirewallChain;
+import static android.net.BpfNetMapsUtils.isFirewallAllowList;
 import static android.net.BpfNetMapsUtils.matchToString;
 import static android.net.ConnectivityManager.FIREWALL_CHAIN_DOZABLE;
 import static android.net.ConnectivityManager.FIREWALL_CHAIN_LOW_POWER_STANDBY;
@@ -51,7 +58,9 @@
 
 import android.app.StatsManager;
 import android.content.Context;
+import android.net.BpfNetMapsReader;
 import android.net.INetd;
+import android.net.UidOwnerValue;
 import android.os.Build;
 import android.os.RemoteException;
 import android.os.ServiceSpecificException;
@@ -78,9 +87,12 @@
 import com.android.net.module.util.Struct.U8;
 import com.android.net.module.util.bpf.CookieTagMapKey;
 import com.android.net.module.util.bpf.CookieTagMapValue;
+import com.android.net.module.util.bpf.IngressDiscardKey;
+import com.android.net.module.util.bpf.IngressDiscardValue;
 
 import java.io.FileDescriptor;
 import java.io.IOException;
+import java.net.InetAddress;
 import java.util.Arrays;
 import java.util.List;
 import java.util.Set;
@@ -92,7 +104,6 @@
  * {@hide}
  */
 public class BpfNetMaps {
-    private static final boolean PRE_T = !SdkLevel.isAtLeastT();
     static {
         if (!PRE_T) {
             System.loadLibrary("service-connectivity");
@@ -128,6 +139,9 @@
     private static IBpfMap<S32, UidOwnerValue> sUidOwnerMap = null;
     private static IBpfMap<S32, U8> sUidPermissionMap = null;
     private static IBpfMap<CookieTagMapKey, CookieTagMapValue> sCookieTagMap = null;
+    // TODO: Add BOOL class and replace U8?
+    private static IBpfMap<S32, U8> sDataSaverEnabledMap = null;
+    private static IBpfMap<IngressDiscardKey, IngressDiscardValue> sIngressDiscardMap = null;
 
     private static final List<Pair<Integer, String>> PERMISSION_LIST = Arrays.asList(
             Pair.create(PERMISSION_INTERNET, "PERMISSION_INTERNET"),
@@ -175,6 +189,23 @@
         sCookieTagMap = cookieTagMap;
     }
 
+    /**
+     * Set dataSaverEnabledMap for test.
+     */
+    @VisibleForTesting
+    public static void setDataSaverEnabledMapForTest(IBpfMap<S32, U8> dataSaverEnabledMap) {
+        sDataSaverEnabledMap = dataSaverEnabledMap;
+    }
+
+    /**
+     * Set ingressDiscardMap for test.
+     */
+    @VisibleForTesting
+    public static void setIngressDiscardMapForTest(
+            IBpfMap<IngressDiscardKey, IngressDiscardValue> ingressDiscardMap) {
+        sIngressDiscardMap = ingressDiscardMap;
+    }
+
     private static IBpfMap<S32, U32> getConfigurationMap() {
         try {
             return new BpfMap<>(
@@ -211,6 +242,24 @@
         }
     }
 
+    private static IBpfMap<S32, U8> getDataSaverEnabledMap() {
+        try {
+            return new BpfMap<>(
+                    DATA_SAVER_ENABLED_MAP_PATH, BpfMap.BPF_F_RDWR, S32.class, U8.class);
+        } catch (ErrnoException e) {
+            throw new IllegalStateException("Cannot open data saver enabled map", e);
+        }
+    }
+
+    private static IBpfMap<IngressDiscardKey, IngressDiscardValue> getIngressDiscardMap() {
+        try {
+            return new BpfMap<>(INGRESS_DISCARD_MAP_PATH, BpfMap.BPF_F_RDWR,
+                    IngressDiscardKey.class, IngressDiscardValue.class);
+        } catch (ErrnoException e) {
+            throw new IllegalStateException("Cannot open ingress discard map", e);
+        }
+    }
+
     private static void initBpfMaps() {
         if (sConfigurationMap == null) {
             sConfigurationMap = getConfigurationMap();
@@ -244,6 +293,24 @@
         if (sCookieTagMap == null) {
             sCookieTagMap = getCookieTagMap();
         }
+
+        if (sDataSaverEnabledMap == null) {
+            sDataSaverEnabledMap = getDataSaverEnabledMap();
+        }
+        try {
+            sDataSaverEnabledMap.updateEntry(DATA_SAVER_ENABLED_KEY, new U8(DATA_SAVER_DISABLED));
+        } catch (ErrnoException e) {
+            throw new IllegalStateException("Failed to initialize data saver configuration", e);
+        }
+
+        if (sIngressDiscardMap == null) {
+            sIngressDiscardMap = getIngressDiscardMap();
+        }
+        try {
+            sIngressDiscardMap.clear();
+        } catch (ErrnoException e) {
+            throw new IllegalStateException("Failed to initialize ingress discard map", e);
+        }
     }
 
     /**
@@ -254,7 +321,7 @@
         if (sInitialized) return;
         if (sEnableJavaBpfMap == null) {
             sEnableJavaBpfMap = SdkLevel.isAtLeastU() ||
-                    DeviceConfigUtils.isTetheringFeatureNotChickenedOut(
+                    DeviceConfigUtils.isTetheringFeatureNotChickenedOut(context,
                             BPF_NET_MAPS_FORCE_DISABLE_JAVA_BPF_MAP);
         }
         Log.d(TAG, "BpfNetMaps is initialized with sEnableJavaBpfMap=" + sEnableJavaBpfMap);
@@ -281,6 +348,13 @@
         }
 
         /**
+         * Get interface name
+         */
+        public String getIfName(final int ifIndex) {
+            return Os.if_indextoname(ifIndex);
+        }
+
+        /**
          * Call synchronize_rcu()
          */
         public int synchronizeKernelRCU() {
@@ -298,6 +372,7 @@
     }
 
     /** Constructor used after T that doesn't need to use netd anymore. */
+    @RequiresApi(Build.VERSION_CODES.TIRAMISU)
     public BpfNetMaps(final Context context) {
         this(context, null);
 
@@ -317,29 +392,6 @@
         mDeps = deps;
     }
 
-    /**
-     * Get if the chain is allow list or not.
-     *
-     * ALLOWLIST means the firewall denies all by default, uids must be explicitly allowed
-     * DENYLIST means the firewall allows all by default, uids must be explicitly denyed
-     */
-    public boolean isFirewallAllowList(final int chain) {
-        switch (chain) {
-            case FIREWALL_CHAIN_DOZABLE:
-            case FIREWALL_CHAIN_POWERSAVE:
-            case FIREWALL_CHAIN_RESTRICTED:
-            case FIREWALL_CHAIN_LOW_POWER_STANDBY:
-                return true;
-            case FIREWALL_CHAIN_STANDBY:
-            case FIREWALL_CHAIN_OEM_DENY_1:
-            case FIREWALL_CHAIN_OEM_DENY_2:
-            case FIREWALL_CHAIN_OEM_DENY_3:
-                return false;
-            default:
-                throw new ServiceSpecificException(EINVAL, "Invalid firewall chain: " + chain);
-        }
-    }
-
     private void maybeThrow(final int err, final String msg) {
         if (err != 0) {
             throw new ServiceSpecificException(err, msg + ": " + Os.strerror(err));
@@ -420,6 +472,7 @@
      * @throws ServiceSpecificException in case of failure, with an error code indicating the
      *                                  cause of the failure.
      */
+    @RequiresApi(Build.VERSION_CODES.TIRAMISU)
     public void addNaughtyApp(final int uid) {
         throwIfPreT("addNaughtyApp is not available on pre-T devices");
 
@@ -438,6 +491,7 @@
      * @throws ServiceSpecificException in case of failure, with an error code indicating the
      *                                  cause of the failure.
      */
+    @RequiresApi(Build.VERSION_CODES.TIRAMISU)
     public void removeNaughtyApp(final int uid) {
         throwIfPreT("removeNaughtyApp is not available on pre-T devices");
 
@@ -456,6 +510,7 @@
      * @throws ServiceSpecificException in case of failure, with an error code indicating the
      *                                  cause of the failure.
      */
+    @RequiresApi(Build.VERSION_CODES.TIRAMISU)
     public void addNiceApp(final int uid) {
         throwIfPreT("addNiceApp is not available on pre-T devices");
 
@@ -474,6 +529,7 @@
      * @throws ServiceSpecificException in case of failure, with an error code indicating the
      *                                  cause of the failure.
      */
+    @RequiresApi(Build.VERSION_CODES.TIRAMISU)
     public void removeNiceApp(final int uid) {
         throwIfPreT("removeNiceApp is not available on pre-T devices");
 
@@ -494,6 +550,7 @@
      * @throws ServiceSpecificException in case of failure, with an error code indicating the
      *                                  cause of the failure.
      */
+    @RequiresApi(Build.VERSION_CODES.TIRAMISU)
     public void setChildChain(final int childChain, final boolean enable) {
         throwIfPreT("setChildChain is not available on pre-T devices");
 
@@ -523,23 +580,19 @@
      * @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) {
-        throwIfPreT("isChainEnabled is not available on pre-T devices");
-
-        final long match = getMatchByFirewallChain(childChain);
-        try {
-            final U32 config = sConfigurationMap.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));
-        }
+        return BpfNetMapsReader.isChainEnabled(sConfigurationMap, childChain);
     }
 
     private Set<Integer> asSet(final int[] uids) {
         final Set<Integer> uidSet = new ArraySet<>();
-        for (final int uid: uids) {
+        for (final int uid : uids) {
             uidSet.add(uid);
         }
         return uidSet;
@@ -554,6 +607,7 @@
      * @throws UnsupportedOperationException if called on pre-T devices.
      * @throws IllegalArgumentException if {@code chain} is not a valid chain.
      */
+    @RequiresApi(Build.VERSION_CODES.TIRAMISU)
     public void replaceUidChain(final int chain, final int[] uids) {
         throwIfPreT("replaceUidChain is not available on pre-T devices");
 
@@ -638,6 +692,7 @@
      * @throws ServiceSpecificException in case of failure, with an error code indicating the
      *                                  cause of the failure.
      */
+    @RequiresApi(Build.VERSION_CODES.TIRAMISU)
     public void setUidRule(final int childChain, final int uid, final int firewallRule) {
         throwIfPreT("setUidRule is not available on pre-T devices");
 
@@ -667,20 +722,12 @@
      * @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) {
-        throwIfPreT("isUidChainEnabled is not available on pre-T devices");
-
-        final long match = getMatchByFirewallChain(childChain);
-        final boolean isAllowList = isFirewallAllowList(childChain);
-        try {
-            final UidOwnerValue uidMatch = sUidOwnerMap.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 BpfNetMapsReader.getUidRule(sUidOwnerMap, childChain, uid);
     }
 
     private Set<Integer> getUidsMatchEnabled(final int childChain) throws ErrnoException {
@@ -830,6 +877,7 @@
      * @throws ServiceSpecificException in case of failure, with an error code indicating the
      *                                  cause of the failure.
      */
+    @RequiresApi(Build.VERSION_CODES.TIRAMISU)
     public void updateUidLockdownRule(final int uid, final boolean add) {
         throwIfPreT("updateUidLockdownRule is not available on pre-T devices");
 
@@ -852,6 +900,7 @@
      * @throws ServiceSpecificException in case of failure, with an error code indicating the
      *                                  cause of the failure.
      */
+    @RequiresApi(Build.VERSION_CODES.TIRAMISU)
     public void swapActiveStatsMap() {
         throwIfPreT("swapActiveStatsMap is not available on pre-T devices");
 
@@ -926,7 +975,68 @@
         }
     }
 
+    /**
+     * Set Data Saver enabled or disabled
+     *
+     * @param enable     whether Data Saver is enabled or disabled.
+     * @throws UnsupportedOperationException if called on pre-T devices.
+     * @throws ServiceSpecificException in case of failure, with an error code indicating the
+     *                                  cause of the failure.
+     */
+    @RequiresApi(Build.VERSION_CODES.TIRAMISU)
+    public void setDataSaverEnabled(boolean enable) {
+        throwIfPreT("setDataSaverEnabled is not available on pre-T devices");
+
+        try {
+            final short config = enable ? DATA_SAVER_ENABLED : DATA_SAVER_DISABLED;
+            sDataSaverEnabledMap.updateEntry(DATA_SAVER_ENABLED_KEY, new U8(config));
+        } catch (ErrnoException e) {
+            throw new ServiceSpecificException(e.errno, "Unable to set data saver: "
+                    + Os.strerror(e.errno));
+        }
+    }
+
+    /**
+     * Set ingress discard rule
+     *
+     * @param address target address to set the ingress discard rule
+     * @param iface allowed interface
+     */
+    @RequiresApi(Build.VERSION_CODES.TIRAMISU)
+    public void setIngressDiscardRule(final InetAddress address, final String iface) {
+        throwIfPreT("setIngressDiscardRule is not available on pre-T devices");
+        final int ifIndex = mDeps.getIfIndex(iface);
+        if (ifIndex == 0) {
+            Log.e(TAG, "Failed to get if index, skip setting ingress discard rule for " + address
+                    + "(" + iface + ")");
+            return;
+        }
+        try {
+            sIngressDiscardMap.updateEntry(new IngressDiscardKey(address),
+                    new IngressDiscardValue(ifIndex, ifIndex));
+        } catch (ErrnoException e) {
+            Log.e(TAG, "Failed to set ingress discard rule for " + address + "("
+                    + iface + "), " + e);
+        }
+    }
+
+    /**
+     * Remove ingress discard rule
+     *
+     * @param address target address to remove the ingress discard rule
+     */
+    @RequiresApi(Build.VERSION_CODES.TIRAMISU)
+    public void removeIngressDiscardRule(final InetAddress address) {
+        throwIfPreT("removeIngressDiscardRule is not available on pre-T devices");
+        try {
+            sIngressDiscardMap.deleteEntry(new IngressDiscardKey(address));
+        } catch (ErrnoException e) {
+            Log.e(TAG, "Failed to remove ingress discard rule for " + address + ", " + e);
+        }
+    }
+
     /** Register callback for statsd to pull atom. */
+    @RequiresApi(Build.VERSION_CODES.TIRAMISU)
     public void setPullAtomCallback(final Context context) {
         throwIfPreT("setPullAtomCallback is not available on pre-T devices");
 
@@ -1007,6 +1117,15 @@
         }
     }
 
+    private void dumpDataSaverConfig(final IndentingPrintWriter pw) {
+        try {
+            final short config = sDataSaverEnabledMap.getValue(DATA_SAVER_ENABLED_KEY).val;
+            // Any non-zero value converted from short to boolean is true by convention.
+            pw.println("sDataSaverEnabledMap: " + (config != DATA_SAVER_DISABLED));
+        } catch (ErrnoException e) {
+            pw.println("Failed to read data saver configuration: " + e);
+        }
+    }
     /**
      * Dump BPF maps
      *
@@ -1016,6 +1135,7 @@
      * @throws IOException when file descriptor is invalid.
      * @throws ServiceSpecificException when the method is called on an unsupported device.
      */
+    @RequiresApi(Build.VERSION_CODES.TIRAMISU)
     public void dump(final IndentingPrintWriter pw, final FileDescriptor fd, boolean verbose)
             throws IOException, ServiceSpecificException {
         if (PRE_T) {
@@ -1056,6 +1176,11 @@
                     });
             BpfDump.dumpMap(sUidPermissionMap, pw, "sUidPermissionMap",
                     (uid, permission) -> uid.val + " " + permissionToString(permission.val));
+            BpfDump.dumpMap(sIngressDiscardMap, pw, "sIngressDiscardMap",
+                    (key, value) -> "[" + key.dstAddr + "]: "
+                            + value.iif1 + "(" + mDeps.getIfName(value.iif1) + "), "
+                            + value.iif2 + "(" + mDeps.getIfName(value.iif2) + ")");
+            dumpDataSaverConfig(pw);
             pw.decreaseIndent();
         }
     }
diff --git a/service/src/com/android/server/ConnectivityService.java b/service/src/com/android/server/ConnectivityService.java
index 85507f6..50b4134 100755
--- a/service/src/com/android/server/ConnectivityService.java
+++ b/service/src/com/android/server/ConnectivityService.java
@@ -19,6 +19,7 @@
 import static android.Manifest.permission.RECEIVE_DATA_ACTIVITY_CHANGE;
 import static android.app.ActivityManager.UidFrozenStateChangedCallback.UID_FROZEN_STATE_FROZEN;
 import static android.content.pm.PackageManager.FEATURE_BLUETOOTH;
+import static android.content.pm.PackageManager.FEATURE_LEANBACK;
 import static android.content.pm.PackageManager.FEATURE_WATCH;
 import static android.content.pm.PackageManager.FEATURE_WIFI;
 import static android.content.pm.PackageManager.FEATURE_WIFI_DIRECT;
@@ -67,6 +68,7 @@
 import static android.net.NetworkCapabilities.NET_CAPABILITY_ENTERPRISE;
 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;
@@ -98,6 +100,11 @@
 import static android.system.OsConstants.IPPROTO_TCP;
 import static android.system.OsConstants.IPPROTO_UDP;
 
+import static com.android.net.module.util.BpfUtils.BPF_CGROUP_INET4_BIND;
+import static com.android.net.module.util.BpfUtils.BPF_CGROUP_INET6_BIND;
+import static com.android.net.module.util.BpfUtils.BPF_CGROUP_INET_EGRESS;
+import static com.android.net.module.util.BpfUtils.BPF_CGROUP_INET_INGRESS;
+import static com.android.net.module.util.BpfUtils.BPF_CGROUP_INET_SOCK_CREATE;
 import static com.android.net.module.util.NetworkMonitorUtils.isPrivateDnsValidationRequired;
 import static com.android.net.module.util.PermissionUtils.checkAnyPermissionOf;
 import static com.android.net.module.util.PermissionUtils.enforceAnyPermissionOf;
@@ -110,6 +117,7 @@
 import android.Manifest;
 import android.annotation.NonNull;
 import android.annotation.Nullable;
+import android.annotation.SuppressLint;
 import android.annotation.TargetApi;
 import android.app.ActivityManager;
 import android.app.ActivityManager.UidFrozenStateChangedCallback;
@@ -128,6 +136,7 @@
 import android.content.pm.PackageManager;
 import android.content.res.XmlResourceParser;
 import android.database.ContentObserver;
+import android.net.BpfNetMapsUtils;
 import android.net.CaptivePortal;
 import android.net.CaptivePortalData;
 import android.net.ConnectionInfo;
@@ -158,6 +167,7 @@
 import android.net.IpMemoryStore;
 import android.net.IpPrefix;
 import android.net.LinkProperties;
+import android.net.LocalNetworkConfig;
 import android.net.MatchAllNetworkSpecifier;
 import android.net.NativeNetworkConfig;
 import android.net.NativeNetworkType;
@@ -190,7 +200,6 @@
 import android.net.QosSocketFilter;
 import android.net.QosSocketInfo;
 import android.net.RouteInfo;
-import android.net.RouteInfoParcel;
 import android.net.SocketKeepalive;
 import android.net.TetheringManager;
 import android.net.TransportInfo;
@@ -276,6 +285,7 @@
 import com.android.net.module.util.BaseNetdUnsolicitedEventListener;
 import com.android.net.module.util.BinderUtils;
 import com.android.net.module.util.BitUtils;
+import com.android.net.module.util.BpfUtils;
 import com.android.net.module.util.CollectionUtils;
 import com.android.net.module.util.DeviceConfigUtils;
 import com.android.net.module.util.InterfaceParams;
@@ -302,6 +312,7 @@
 import com.android.server.connectivity.DnsManager.PrivateDnsValidationUpdate;
 import com.android.server.connectivity.DscpPolicyTracker;
 import com.android.server.connectivity.FullScore;
+import com.android.server.connectivity.HandlerUtils;
 import com.android.server.connectivity.InvalidTagException;
 import com.android.server.connectivity.KeepaliveResourceUtil;
 import com.android.server.connectivity.KeepaliveTracker;
@@ -319,6 +330,7 @@
 import com.android.server.connectivity.ProfileNetworkPreferenceInfo;
 import com.android.server.connectivity.ProxyTracker;
 import com.android.server.connectivity.QosCallbackTracker;
+import com.android.server.connectivity.RoutingCoordinatorService;
 import com.android.server.connectivity.UidRangeUtils;
 import com.android.server.connectivity.VpnNetworkPreferenceInfo;
 import com.android.server.connectivity.wear.CompanionDeviceManagerProxyService;
@@ -482,6 +494,7 @@
     @GuardedBy("mTNSLock")
     private TestNetworkService mTNS;
     private final CompanionDeviceManagerProxyService mCdmps;
+    private final RoutingCoordinatorService mRoutingCoordinatorService;
 
     private final Object mTNSLock = new Object();
 
@@ -1254,16 +1267,24 @@
         private static final String PRIORITY_ARG = "--dump-priority";
         private static final String PRIORITY_ARG_HIGH = "HIGH";
         private static final String PRIORITY_ARG_NORMAL = "NORMAL";
+        private static final int DUMPSYS_DEFAULT_TIMEOUT_MS = 10_000;
 
         LocalPriorityDump() {}
 
         private void dumpHigh(FileDescriptor fd, PrintWriter pw) {
-            doDump(fd, pw, new String[] {DIAG_ARG});
-            doDump(fd, pw, new String[] {SHORT_ARG});
+            if (!HandlerUtils.runWithScissors(mHandler, () -> {
+                doDump(fd, pw, new String[]{DIAG_ARG});
+                doDump(fd, pw, new String[]{SHORT_ARG});
+            }, DUMPSYS_DEFAULT_TIMEOUT_MS)) {
+                pw.println("dumpHigh timeout");
+            }
         }
 
         private void dumpNormal(FileDescriptor fd, PrintWriter pw, String[] args) {
-            doDump(fd, pw, args);
+            if (!HandlerUtils.runWithScissors(mHandler, () -> doDump(fd, pw, args),
+                    DUMPSYS_DEFAULT_TIMEOUT_MS)) {
+                pw.println("dumpNormal timeout");
+            }
         }
 
         public void dump(FileDescriptor fd, PrintWriter pw, String[] args) {
@@ -1316,6 +1337,10 @@
             return SdkLevel.isAtLeastU();
         }
 
+        public boolean isAtLeastV() {
+            return SdkLevel.isAtLeastV();
+        }
+
         /**
          * Get system properties to use in ConnectivityService.
          */
@@ -1429,7 +1454,7 @@
         public CarrierPrivilegeAuthenticator makeCarrierPrivilegeAuthenticator(
                 @NonNull final Context context, @NonNull final TelephonyManager tm) {
             if (isAtLeastT()) {
-                return new CarrierPrivilegeAuthenticator(context, tm);
+                return new CarrierPrivilegeAuthenticator(context, this, tm);
             } else {
                 return null;
             }
@@ -1512,6 +1537,14 @@
         }
 
         /**
+         * Get BPF program Id from CGROUP. See {@link BpfUtils#getProgramId}.
+         */
+        public int getBpfProgramId(final int attachType)
+                throws IOException {
+            return BpfUtils.getProgramId(attachType);
+        }
+
+        /**
          * Wraps {@link BroadcastOptionsShimImpl#newInstance(BroadcastOptions)}
          */
         // TODO: when available in all active branches:
@@ -1768,7 +1801,7 @@
         mNoServiceNetwork = new NetworkAgentInfo(null,
                 new Network(INetd.UNREACHABLE_NET_ID),
                 new NetworkInfo(TYPE_NONE, 0, "", ""),
-                new LinkProperties(), new NetworkCapabilities(),
+                new LinkProperties(), new NetworkCapabilities(), null /* localNetworkConfig */,
                 new NetworkScore.Builder().setLegacyInt(0).build(), mContext, null,
                 new NetworkAgentConfig(), this, null, null, 0, INVALID_UID,
                 mLingerDelayMs, mQosCallbackTracker, mDeps);
@@ -1795,6 +1828,8 @@
             mCdmps = null;
         }
 
+        mRoutingCoordinatorService = new RoutingCoordinatorService(netd);
+
         mDestroyFrozenSockets = mDeps.isAtLeastU()
                 && mDeps.isFeatureEnabled(context, KEY_DESTROY_FROZEN_SOCKETS_VERSION);
         mDelayDestroyFrozenSockets = mDeps.isAtLeastU()
@@ -3235,6 +3270,26 @@
         pw.decreaseIndent();
     }
 
+    private void dumpBpfProgramStatus(IndentingPrintWriter pw) {
+        pw.println("Bpf Program Status:");
+        pw.increaseIndent();
+        try {
+            pw.print("CGROUP_INET_INGRESS: ");
+            pw.println(mDeps.getBpfProgramId(BPF_CGROUP_INET_INGRESS));
+            pw.print("CGROUP_INET_EGRESS: ");
+            pw.println(mDeps.getBpfProgramId(BPF_CGROUP_INET_EGRESS));
+            pw.print("CGROUP_INET_SOCK_CREATE: ");
+            pw.println(mDeps.getBpfProgramId(BPF_CGROUP_INET_SOCK_CREATE));
+            pw.print("CGROUP_INET4_BIND: ");
+            pw.println(mDeps.getBpfProgramId(BPF_CGROUP_INET4_BIND));
+            pw.print("CGROUP_INET6_BIND: ");
+            pw.println(mDeps.getBpfProgramId(BPF_CGROUP_INET6_BIND));
+        } catch (IOException e) {
+            pw.println("  IOException");
+        }
+        pw.decreaseIndent();
+    }
+
     @VisibleForTesting
     static final String KEY_DESTROY_FROZEN_SOCKETS_VERSION = "destroy_frozen_sockets_version";
     @VisibleForTesting
@@ -3482,6 +3537,8 @@
         sendStickyBroadcast(makeGeneralIntent(info, bcastType));
     }
 
+    // TODO(b/193460475): Remove when tooling supports SystemApi to public API.
+    @SuppressLint("NewApi")
     // TODO: Set the mini sdk to 31 and remove @TargetApi annotation when b/205923322 is addressed.
     @TargetApi(Build.VERSION_CODES.S)
     private void sendStickyBroadcast(Intent intent) {
@@ -3847,6 +3904,9 @@
         dumpCloseFrozenAppSockets(pw);
 
         pw.println();
+        dumpBpfProgramStatus(pw);
+
+        pw.println();
 
         if (!CollectionUtils.contains(args, SHORT_ARG)) {
             pw.println();
@@ -4096,7 +4156,14 @@
 
             switch (msg.what) {
                 case NetworkAgent.EVENT_NETWORK_CAPABILITIES_CHANGED: {
-                    nai.setDeclaredCapabilities((NetworkCapabilities) arg.second);
+                    final NetworkCapabilities proposed = (NetworkCapabilities) arg.second;
+                    if (!nai.respectsNcStructuralConstraints(proposed)) {
+                        Log.wtf(TAG, "Agent " + nai + " violates nc structural constraints : "
+                                + nai.networkCapabilities + " -> " + proposed);
+                        disconnectAndDestroyNetwork(nai);
+                        return;
+                    }
+                    nai.setDeclaredCapabilities(proposed);
                     final NetworkCapabilities sanitized =
                             nai.getDeclaredCapabilitiesSanitized(mCarrierPrivilegeAuthenticator);
                     maybeUpdateWifiRoamTimestamp(nai, sanitized);
@@ -4114,6 +4181,11 @@
                     updateNetworkInfo(nai, info);
                     break;
                 }
+                case NetworkAgent.EVENT_LOCAL_NETWORK_CONFIG_CHANGED: {
+                    final LocalNetworkConfig config = (LocalNetworkConfig) arg.second;
+                    updateLocalNetworkConfig(nai, nai.localNetworkConfig, config);
+                    break;
+                }
                 case NetworkAgent.EVENT_NETWORK_SCORE_CHANGED: {
                     updateNetworkScore(nai, (NetworkScore) arg.second);
                     break;
@@ -4407,7 +4479,7 @@
                 updateCapabilitiesForNetwork(nai);
             } else if (portalChanged) {
                 if (portal && ConnectivitySettingsManager.CAPTIVE_PORTAL_MODE_AVOID
-                        == getCaptivePortalMode()) {
+                        == getCaptivePortalMode(nai)) {
                     if (DBG) log("Avoiding captive portal network: " + nai.toShortString());
                     nai.onPreventAutomaticReconnect();
                     teardownUnneededNetwork(nai);
@@ -4443,7 +4515,13 @@
             }
         }
 
-        private int getCaptivePortalMode() {
+        private int getCaptivePortalMode(@NonNull NetworkAgentInfo nai) {
+            if (nai.networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_BLUETOOTH) &&
+                    mContext.getPackageManager().hasSystemFeature(FEATURE_WATCH)) {
+                // Do not avoid captive portal when network is wear proxy.
+                return ConnectivitySettingsManager.CAPTIVE_PORTAL_MODE_PROMPT;
+            }
+
             return Settings.Global.getInt(mContext.getContentResolver(),
                     ConnectivitySettingsManager.CAPTIVE_PORTAL_MODE,
                     ConnectivitySettingsManager.CAPTIVE_PORTAL_MODE_PROMPT);
@@ -4866,6 +4944,17 @@
             mDefaultInetConditionPublished = 0;
         }
         notifyIfacesChangedForNetworkStats();
+        // If this was a local network forwarded to some upstream, or if some local network was
+        // forwarded to this nai, then disable forwarding rules now.
+        maybeDisableForwardRulesForDisconnectingNai(nai);
+        // If this is a local network with an upstream selector, remove the associated network
+        // request.
+        if (nai.isLocalNetwork()) {
+            final NetworkRequest selector = nai.localNetworkConfig.getUpstreamSelector();
+            if (null != selector) {
+                handleRemoveNetworkRequest(mNetworkRequests.get(selector));
+            }
+        }
         // TODO - we shouldn't send CALLBACK_LOST to requests that can be satisfied
         // by other networks that are already connected. Perhaps that can be done by
         // sending all CALLBACK_LOST messages (for requests, not listens) at the end
@@ -4979,6 +5068,48 @@
         mNetIdManager.releaseNetId(nai.network.getNetId());
     }
 
+    private void maybeDisableForwardRulesForDisconnectingNai(
+            @NonNull final NetworkAgentInfo disconnecting) {
+        // Step 1 : maybe this network was the upstream for one or more local networks.
+        for (final NetworkAgentInfo local : mNetworkAgentInfos) {
+            if (!local.isLocalNetwork()) continue;
+            final NetworkRequest selector = local.localNetworkConfig.getUpstreamSelector();
+            if (null == selector) continue;
+            final NetworkRequestInfo nri = mNetworkRequests.get(selector);
+            // null == nri can happen while disconnecting a network, because destroyNetwork() is
+            // called after removing all associated NRIs from mNetworkRequests.
+            if (null == nri) continue;
+            final NetworkAgentInfo satisfier = nri.getSatisfier();
+            if (disconnecting != satisfier) continue;
+            removeLocalNetworkUpstream(local, disconnecting);
+        }
+
+        // Step 2 : maybe this is a local network that had an upstream.
+        if (!disconnecting.isLocalNetwork()) return;
+        final NetworkRequest selector = disconnecting.localNetworkConfig.getUpstreamSelector();
+        if (null == selector) return;
+        final NetworkRequestInfo nri = mNetworkRequests.get(selector);
+        // As above null == nri can happen while disconnecting a network, because destroyNetwork()
+        // is called after removing all associated NRIs from mNetworkRequests.
+        if (null == nri) return;
+        final NetworkAgentInfo satisfier = nri.getSatisfier();
+        if (null == satisfier) return;
+        removeLocalNetworkUpstream(disconnecting, satisfier);
+    }
+
+    private void removeLocalNetworkUpstream(@NonNull final NetworkAgentInfo localAgent,
+            @NonNull final NetworkAgentInfo upstream) {
+        try {
+            mRoutingCoordinatorService.removeInterfaceForward(
+                    localAgent.linkProperties.getInterfaceName(),
+                    upstream.linkProperties.getInterfaceName());
+        } catch (RemoteException e) {
+            loge("Couldn't remove interface forward for "
+                    + localAgent.linkProperties.getInterfaceName() + " to "
+                    + upstream.linkProperties.getInterfaceName() + " while disconnecting");
+        }
+    }
+
     private boolean createNativeNetwork(@NonNull NetworkAgentInfo nai) {
         try {
             // This should never fail.  Specifying an already in use NetID will cause failure.
@@ -4993,7 +5124,9 @@
                         !nai.networkAgentConfig.allowBypass /* secure */,
                         getVpnType(nai), nai.networkAgentConfig.excludeLocalRouteVpn);
             } else {
-                config = new NativeNetworkConfig(nai.network.getNetId(), NativeNetworkType.PHYSICAL,
+                config = new NativeNetworkConfig(nai.network.getNetId(),
+                        nai.isLocalNetwork() ? NativeNetworkType.PHYSICAL_LOCAL
+                                : NativeNetworkType.PHYSICAL,
                         getNetworkPermission(nai.networkCapabilities),
                         false /* secure */,
                         VpnManager.TYPE_VPN_NONE,
@@ -5001,8 +5134,8 @@
             }
             mNetd.networkCreate(config);
             mDnsResolver.createNetworkCache(nai.network.getNetId());
-            mDnsManager.updateTransportsForNetwork(nai.network.getNetId(),
-                    nai.networkCapabilities.getTransportTypes());
+            mDnsManager.updateCapabilitiesForNetwork(nai.network.getNetId(),
+                    nai.networkCapabilities);
             return true;
         } catch (RemoteException | ServiceSpecificException e) {
             loge("Error creating network " + nai.toShortString() + ": " + e.getMessage());
@@ -5014,6 +5147,9 @@
         if (mDscpPolicyTracker != null) {
             mDscpPolicyTracker.removeAllDscpPolicies(nai, false);
         }
+        // Remove any forwarding rules to and from the interface for this network, since
+        // the interface is going to go away.
+        maybeDisableForwardRulesForDisconnectingNai(nai);
         try {
             mNetd.networkDestroy(nai.network.getNetId());
         } catch (RemoteException | ServiceSpecificException e) {
@@ -5200,7 +5336,14 @@
 
     private boolean isNetworkPotentialSatisfier(
             @NonNull final NetworkAgentInfo candidate, @NonNull final NetworkRequestInfo nri) {
-        // listen requests won't keep up a network satisfying it. If this is not a multilayer
+        // While destroyed network sometimes satisfy requests (including occasionally newly
+        // satisfying requests), *potential* satisfiers are networks that might beat a current
+        // champion if they validate. As such, a destroyed network is never a potential satisfier,
+        // because it's never a good idea to keep a destroyed network in case it validates.
+        // For example, declaring it a potential satisfier would keep an unvalidated destroyed
+        // candidate after it's been replaced by another unvalidated network.
+        if (candidate.isDestroyed()) return false;
+        // Listen requests won't keep up a network satisfying it. If this is not a multilayer
         // request, return immediately. For multilayer requests, check to see if any of the
         // multilayer requests may have a potential satisfier.
         if (!nri.isMultilayerRequest() && (nri.mRequests.get(0).isListen()
@@ -5218,8 +5361,12 @@
             if (req.isListen() || req.isListenForBest()) {
                 continue;
             }
-            // If this Network is already the best Network for a request, or if
-            // there is hope for it to become one if it validated, then it is needed.
+            // If there is hope for this network might validate and subsequently become the best
+            // network for that request, then it is needed. Note that this network can't already
+            // be the best for this request, or it would be the current satisfier, and therefore
+            // there would be no need to call this method to find out if it is a *potential*
+            // satisfier ("unneeded", the only caller, only calls this if this network currently
+            // satisfies no request).
             if (candidate.satisfies(req)) {
                 // As soon as a network is found that satisfies a request, return. Specifically for
                 // multilayer requests, returning as soon as a NetworkAgentInfo satisfies a request
@@ -8021,6 +8168,7 @@
                 }
             }
         }
+        if (!highestPriorityNri.isBeingSatisfied()) return null;
         return highestPriorityNri.getSatisfier();
     }
 
@@ -8043,6 +8191,18 @@
     }
 
     /**
+     * Returns whether local agents are supported on this device.
+     *
+     * Local agents are supported from U on TVs, and from V on all devices.
+     */
+    @VisibleForTesting
+    public boolean areLocalAgentsSupported() {
+        final PackageManager pm = mContext.getPackageManager();
+        // Local agents are supported starting on U on TVs and on V on everything else.
+        return mDeps.isAtLeastV() || (mDeps.isAtLeastU() && pm.hasSystemFeature(FEATURE_LEANBACK));
+    }
+
+    /**
      * Register a new agent with ConnectivityService to handle a network.
      *
      * @param na a reference for ConnectivityService to contact the agent asynchronously.
@@ -8053,13 +8213,18 @@
      * @param networkCapabilities the initial capabilites of this network. They can be updated
      *         later : see {@link #updateCapabilities}.
      * @param initialScore the initial score of the network. See {@link NetworkAgentInfo#getScore}.
+     * @param localNetworkConfig config about this local network, or null if not a local network
      * @param networkAgentConfig metadata about the network. This is never updated.
      * @param providerId the ID of the provider owning this NetworkAgent.
      * @return the network created for this agent.
      */
-    public Network registerNetworkAgent(INetworkAgent na, NetworkInfo networkInfo,
-            LinkProperties linkProperties, NetworkCapabilities networkCapabilities,
-            @NonNull NetworkScore initialScore, NetworkAgentConfig networkAgentConfig,
+    public Network registerNetworkAgent(INetworkAgent na,
+            NetworkInfo networkInfo,
+            LinkProperties linkProperties,
+            NetworkCapabilities networkCapabilities,
+            @NonNull NetworkScore initialScore,
+            @Nullable LocalNetworkConfig localNetworkConfig,
+            NetworkAgentConfig networkAgentConfig,
             int providerId) {
         Objects.requireNonNull(networkInfo, "networkInfo must not be null");
         Objects.requireNonNull(linkProperties, "linkProperties must not be null");
@@ -8071,12 +8236,26 @@
         } else {
             enforceNetworkFactoryPermission();
         }
+        final boolean hasLocalCap =
+                networkCapabilities.hasCapability(NET_CAPABILITY_LOCAL_NETWORK);
+        if (hasLocalCap && !areLocalAgentsSupported()) {
+            // Before U, netd doesn't support PHYSICAL_LOCAL networks so this can't work.
+            throw new IllegalArgumentException("Local agents are not supported in this version");
+        }
+        final boolean hasLocalNetworkConfig = null != localNetworkConfig;
+        if (hasLocalCap != hasLocalNetworkConfig) {
+            throw new IllegalArgumentException(null != localNetworkConfig
+                    ? "Only local network agents can have a LocalNetworkConfig"
+                    : "Local network agents must have a LocalNetworkConfig"
+            );
+        }
 
         final int uid = mDeps.getCallingUid();
         final long token = Binder.clearCallingIdentity();
         try {
             return registerNetworkAgentInternal(na, networkInfo, linkProperties,
-                    networkCapabilities, initialScore, networkAgentConfig, providerId, uid);
+                    networkCapabilities, initialScore, networkAgentConfig, localNetworkConfig,
+                    providerId, uid);
         } finally {
             Binder.restoreCallingIdentity(token);
         }
@@ -8084,7 +8263,8 @@
 
     private Network registerNetworkAgentInternal(INetworkAgent na, NetworkInfo networkInfo,
             LinkProperties linkProperties, NetworkCapabilities networkCapabilities,
-            NetworkScore currentScore, NetworkAgentConfig networkAgentConfig, int providerId,
+            NetworkScore currentScore, NetworkAgentConfig networkAgentConfig,
+            @Nullable LocalNetworkConfig localNetworkConfig, int providerId,
             int uid) {
 
         // Make a copy of the passed NI, LP, NC as the caller may hold a reference to them
@@ -8092,6 +8272,7 @@
         final NetworkInfo niCopy = new NetworkInfo(networkInfo);
         final NetworkCapabilities ncCopy = new NetworkCapabilities(networkCapabilities);
         final LinkProperties lpCopy = new LinkProperties(linkProperties);
+        // No need to copy |localNetworkConfiguration| as it is immutable.
 
         // At this point the capabilities/properties are untrusted and unverified, e.g. checks that
         // the capabilities' access UIDs comply with security limitations. They will be sanitized
@@ -8099,9 +8280,9 @@
         // because some of the checks must happen on the handler thread.
         final NetworkAgentInfo nai = new NetworkAgentInfo(na,
                 new Network(mNetIdManager.reserveNetId()), niCopy, lpCopy, ncCopy,
-                currentScore, mContext, mTrackerHandler, new NetworkAgentConfig(networkAgentConfig),
-                this, mNetd, mDnsResolver, providerId, uid, mLingerDelayMs,
-                mQosCallbackTracker, mDeps);
+                localNetworkConfig, currentScore, mContext, mTrackerHandler,
+                new NetworkAgentConfig(networkAgentConfig), this, mNetd, mDnsResolver, providerId,
+                uid, mLingerDelayMs, mQosCallbackTracker, mDeps);
 
         final String extraInfo = niCopy.getExtraInfo();
         final String name = TextUtils.isEmpty(extraInfo)
@@ -8138,6 +8319,9 @@
             e.rethrowAsRuntimeException();
         }
 
+        if (nai.isLocalNetwork()) {
+            updateLocalNetworkConfig(nai, null /* oldConfig */, nai.localNetworkConfig);
+        }
         nai.notifyRegistered();
         NetworkInfo networkInfo = nai.networkInfo;
         updateNetworkInfo(nai, networkInfo);
@@ -8411,7 +8595,7 @@
             for (final String iface : interfaceDiff.added) {
                 try {
                     if (DBG) log("Adding iface " + iface + " to network " + netId);
-                    mNetd.networkAddInterface(netId, iface);
+                    mRoutingCoordinatorService.addInterfaceToNetwork(netId, iface);
                     wakeupModifyInterface(iface, nai, true);
                     mDeps.reportNetworkInterfaceForTransports(mContext, iface,
                             nai.networkCapabilities.getTransportTypes());
@@ -8424,45 +8608,13 @@
             try {
                 if (DBG) log("Removing iface " + iface + " from network " + netId);
                 wakeupModifyInterface(iface, nai, false);
-                mNetd.networkRemoveInterface(netId, iface);
+                mRoutingCoordinatorService.removeInterfaceFromNetwork(netId, iface);
             } catch (Exception e) {
                 loge("Exception removing interface: " + e);
             }
         }
     }
 
-    // TODO: move to frameworks/libs/net.
-    private RouteInfoParcel convertRouteInfo(RouteInfo route) {
-        final String nextHop;
-
-        switch (route.getType()) {
-            case RouteInfo.RTN_UNICAST:
-                if (route.hasGateway()) {
-                    nextHop = route.getGateway().getHostAddress();
-                } else {
-                    nextHop = INetd.NEXTHOP_NONE;
-                }
-                break;
-            case RouteInfo.RTN_UNREACHABLE:
-                nextHop = INetd.NEXTHOP_UNREACHABLE;
-                break;
-            case RouteInfo.RTN_THROW:
-                nextHop = INetd.NEXTHOP_THROW;
-                break;
-            default:
-                nextHop = INetd.NEXTHOP_NONE;
-                break;
-        }
-
-        final RouteInfoParcel rip = new RouteInfoParcel();
-        rip.ifName = route.getInterface();
-        rip.destination = route.getDestination().toString();
-        rip.nextHop = nextHop;
-        rip.mtu = route.getMtu();
-
-        return rip;
-    }
-
     /**
      * Have netd update routes from oldLp to newLp.
      * @return true if routes changed between oldLp and newLp
@@ -8483,10 +8635,10 @@
             if (route.hasGateway()) continue;
             if (VDBG || DDBG) log("Adding Route [" + route + "] to network " + netId);
             try {
-                mNetd.networkAddRouteParcel(netId, convertRouteInfo(route));
+                mRoutingCoordinatorService.addRoute(netId, route);
             } catch (Exception e) {
                 if ((route.getDestination().getAddress() instanceof Inet4Address) || VDBG) {
-                    loge("Exception in networkAddRouteParcel for non-gateway: " + e);
+                    loge("Exception in addRoute for non-gateway: " + e);
                 }
             }
         }
@@ -8494,10 +8646,10 @@
             if (!route.hasGateway()) continue;
             if (VDBG || DDBG) log("Adding Route [" + route + "] to network " + netId);
             try {
-                mNetd.networkAddRouteParcel(netId, convertRouteInfo(route));
+                mRoutingCoordinatorService.addRoute(netId, route);
             } catch (Exception e) {
                 if ((route.getGateway() instanceof Inet4Address) || VDBG) {
-                    loge("Exception in networkAddRouteParcel for gateway: " + e);
+                    loge("Exception in addRoute for gateway: " + e);
                 }
             }
         }
@@ -8505,18 +8657,18 @@
         for (RouteInfo route : routeDiff.removed) {
             if (VDBG || DDBG) log("Removing Route [" + route + "] from network " + netId);
             try {
-                mNetd.networkRemoveRouteParcel(netId, convertRouteInfo(route));
+                mRoutingCoordinatorService.removeRoute(netId, route);
             } catch (Exception e) {
-                loge("Exception in networkRemoveRouteParcel: " + e);
+                loge("Exception in removeRoute: " + e);
             }
         }
 
         for (RouteInfo route : routeDiff.updated) {
             if (VDBG || DDBG) log("Updating Route [" + route + "] from network " + netId);
             try {
-                mNetd.networkUpdateRouteParcel(netId, convertRouteInfo(route));
+                mRoutingCoordinatorService.updateRoute(netId, route);
             } catch (Exception e) {
-                loge("Exception in networkUpdateRouteParcel: " + e);
+                loge("Exception in updateRoute: " + e);
             }
         }
         return !routeDiff.added.isEmpty() || !routeDiff.removed.isEmpty()
@@ -8823,9 +8975,8 @@
         // This network might have been underlying another network. Propagate its capabilities.
         propagateUnderlyingNetworkCapabilities(nai.network);
 
-        if (!newNc.equalsTransportTypes(prevNc)) {
-            mDnsManager.updateTransportsForNetwork(
-                    nai.network.getNetId(), newNc.getTransportTypes());
+        if (meteredChanged || !newNc.equalsTransportTypes(prevNc)) {
+            mDnsManager.updateCapabilitiesForNetwork(nai.network.getNetId(), newNc);
         }
 
         maybeSendProxyBroadcast(nai, prevNc, newNc);
@@ -8836,6 +8987,69 @@
         updateCapabilities(nai.getScore(), nai, nai.networkCapabilities);
     }
 
+    // oldConfig is null iff this is the original registration of the local network config
+    private void updateLocalNetworkConfig(@NonNull final NetworkAgentInfo nai,
+            @Nullable final LocalNetworkConfig oldConfig,
+            @NonNull final LocalNetworkConfig newConfig) {
+        if (!nai.isLocalNetwork()) {
+            Log.wtf(TAG, "Ignoring update of a local network info on non-local network " + nai);
+            return;
+        }
+
+        final LocalNetworkConfig.Builder configBuilder = new LocalNetworkConfig.Builder();
+        // TODO : apply the diff for multicast routing.
+        configBuilder.setUpstreamMulticastRoutingConfig(
+                newConfig.getUpstreamMulticastRoutingConfig());
+        configBuilder.setDownstreamMulticastRoutingConfig(
+                newConfig.getDownstreamMulticastRoutingConfig());
+
+        final NetworkRequest oldRequest =
+                (null == oldConfig) ? null : oldConfig.getUpstreamSelector();
+        final NetworkCapabilities oldCaps =
+                (null == oldRequest) ? null : oldRequest.networkCapabilities;
+        final NetworkRequestInfo oldNri =
+                null == oldRequest ? null : mNetworkRequests.get(oldRequest);
+        final NetworkAgentInfo oldSatisfier =
+                null == oldNri ? null : oldNri.getSatisfier();
+        final NetworkRequest newRequest = newConfig.getUpstreamSelector();
+        final NetworkCapabilities newCaps =
+                (null == newRequest) ? null : newRequest.networkCapabilities;
+        final boolean requestUpdated = !Objects.equals(newCaps, oldCaps);
+        if (null != oldRequest && requestUpdated) {
+            handleRemoveNetworkRequest(mNetworkRequests.get(oldRequest));
+            if (null == newRequest && null != oldSatisfier) {
+                // If there is an old satisfier, but no new request, then remove the old upstream.
+                removeLocalNetworkUpstream(nai, oldSatisfier);
+                nai.localNetworkConfig = configBuilder.build();
+                return;
+            }
+        }
+        if (null != newRequest && requestUpdated) {
+            // File the new request if :
+            //  - it has changed (requestUpdated), or
+            //  - it's the first time this local info (null == oldConfig)
+            // is updated and the request has not been filed yet.
+            // Requests for local info are always LISTEN_FOR_BEST, because they have at most one
+            // upstream (the best) but never request it to be brought up.
+            final NetworkRequest nr = new NetworkRequest(newCaps, ConnectivityManager.TYPE_NONE,
+                    nextNetworkRequestId(), LISTEN_FOR_BEST);
+            configBuilder.setUpstreamSelector(nr);
+            final NetworkRequestInfo nri = new NetworkRequestInfo(
+                    nai.creatorUid, nr, null /* messenger */, null /* binder */,
+                    0 /* callbackFlags */, null /* attributionTag */);
+            if (null != oldSatisfier) {
+                // Set the old satisfier in the new NRI so that the rematch will see any changes
+                nri.setSatisfier(oldSatisfier, nr);
+            }
+            nai.localNetworkConfig = configBuilder.build();
+            handleRegisterNetworkRequest(nri);
+        } else {
+            configBuilder.setUpstreamSelector(oldRequest);
+            nai.localNetworkConfig = configBuilder.build();
+        }
+
+    }
+
     /**
      * Returns the interface which requires VPN isolation (ingress interface filtering).
      *
@@ -9131,6 +9345,8 @@
         // else not handled
     }
 
+    // TODO(b/193460475): Remove when tooling supports SystemApi to public API.
+    @SuppressLint("NewApi")
     private void sendIntent(PendingIntent pendingIntent, Intent intent) {
         mPendingIntentWakeLock.acquire();
         try {
@@ -9174,7 +9390,7 @@
             // are Type.LISTEN, but should not have NetworkCallbacks invoked.
             return;
         }
-        Bundle bundle = new Bundle();
+        final Bundle bundle = new Bundle();
         // TODO b/177608132: make sure callbacks are indexed by NRIs and not NetworkRequest objects.
         // TODO: check if defensive copies of data is needed.
         final NetworkRequest nrForCallback = nri.getNetworkRequestForCallback();
@@ -9613,7 +9829,8 @@
             if (VDBG) log("rematch for " + newSatisfier.toShortString());
             if (null != previousRequest && null != previousSatisfier) {
                 if (VDBG || DDBG) {
-                    log("   accepting network in place of " + previousSatisfier.toShortString());
+                    log("   accepting network in place of " + previousSatisfier.toShortString()
+                            + " for " + newRequest);
                 }
                 previousSatisfier.removeRequest(previousRequest.requestId);
                 if (canSupportGracefulNetworkSwitch(previousSatisfier, newSatisfier)
@@ -9632,7 +9849,7 @@
                     previousSatisfier.lingerRequest(previousRequest.requestId, now);
                 }
             } else {
-                if (VDBG || DDBG) log("   accepting network in place of null");
+                if (VDBG || DDBG) log("   accepting network in place of null for " + newRequest);
             }
 
             // To prevent constantly CPU wake up for nascent timer, if a network comes up
@@ -9748,6 +9965,14 @@
         }
     }
 
+    private boolean hasSameInterfaceName(@Nullable final NetworkAgentInfo nai1,
+            @Nullable final NetworkAgentInfo nai2) {
+        if (null == nai1) return null == nai2;
+        if (null == nai2) return false;
+        return nai1.linkProperties.getInterfaceName()
+                .equals(nai2.linkProperties.getInterfaceName());
+    }
+
     private void applyNetworkReassignment(@NonNull final NetworkReassignment changes,
             final long now) {
         final Collection<NetworkAgentInfo> nais = mNetworkAgentInfos;
@@ -9821,6 +10046,39 @@
             notifyNetworkLosing(nai, now);
         }
 
+        // Update forwarding rules for the upstreams of local networks. Do this after sending
+        // onAvailable so that clients understand what network this is about.
+        for (final NetworkAgentInfo nai : mNetworkAgentInfos) {
+            if (!nai.isLocalNetwork()) continue;
+            final NetworkRequest nr = nai.localNetworkConfig.getUpstreamSelector();
+            if (null == nr) continue; // No upstream for this local network
+            final NetworkRequestInfo nri = mNetworkRequests.get(nr);
+            final NetworkReassignment.RequestReassignment change = changes.getReassignment(nri);
+            if (null == change) continue; // No change in upstreams for this network
+            final String fromIface = nai.linkProperties.getInterfaceName();
+            if (!hasSameInterfaceName(change.mOldNetwork, change.mNewNetwork)
+                    || change.mOldNetwork.isDestroyed()) {
+                // There can be a change with the same interface name if the new network is the
+                // replacement for the old network that was unregisteredAfterReplacement.
+                try {
+                    if (null != change.mOldNetwork) {
+                        mRoutingCoordinatorService.removeInterfaceForward(fromIface,
+                                change.mOldNetwork.linkProperties.getInterfaceName());
+                    }
+                    // If the new upstream is already destroyed, there is no point in setting up
+                    // a forward (in fact, it might forward to the interface for some new network !)
+                    // Later when the upstream disconnects CS will try to remove the forward, which
+                    // is ignored with a benign log by RoutingCoordinatorService.
+                    if (null != change.mNewNetwork && !change.mNewNetwork.isDestroyed()) {
+                        mRoutingCoordinatorService.addInterfaceForward(fromIface,
+                                change.mNewNetwork.linkProperties.getInterfaceName());
+                    }
+                } catch (final RemoteException e) {
+                    loge("Can't update forwarding rules", e);
+                }
+            }
+        }
+
         updateLegacyTypeTrackerAndVpnLockdownForRematch(changes, nais);
 
         // Tear down all unneeded networks.
@@ -10145,7 +10403,7 @@
             // If a rate limit has been configured and is applicable to this network (network
             // provides internet connectivity), apply it. The tc police filter cannot be attached
             // before the clsact qdisc is added which happens as part of updateLinkProperties ->
-            // updateInterfaces -> INetd#networkAddInterface.
+            // updateInterfaces -> RoutingCoordinatorManager#addInterfaceToNetwork
             // Note: in case of a system server crash, the NetworkController constructor in netd
             // (called when netd starts up) deletes the clsact qdisc of all interfaces.
             if (canNetworkBeRateLimited(networkAgent) && mIngressRateLimit >= 0) {
@@ -10614,6 +10872,16 @@
                 err.getFileDescriptor(), args);
     }
 
+    private Boolean parseBooleanArgument(final String arg) {
+        if ("true".equals(arg)) {
+            return true;
+        } else if ("false".equals(arg)) {
+            return false;
+        } else {
+            return null;
+        }
+    }
+
     private class ShellCmd extends BasicShellCommandHandler {
         @Override
         public int onCommand(String cmd) {
@@ -10643,6 +10911,54 @@
                             onHelp();
                             return -1;
                         }
+                    case "set-chain3-enabled": {
+                        final Boolean enabled = parseBooleanArgument(getNextArg());
+                        if (null == enabled) {
+                            onHelp();
+                            return -1;
+                        }
+                        Log.i(TAG, (enabled ? "En" : "Dis") + "abled FIREWALL_CHAIN_OEM_DENY_3");
+                        setFirewallChainEnabled(ConnectivityManager.FIREWALL_CHAIN_OEM_DENY_3,
+                                enabled);
+                        return 0;
+                    }
+                    case "get-chain3-enabled": {
+                        final boolean chainEnabled = getFirewallChainEnabled(
+                                ConnectivityManager.FIREWALL_CHAIN_OEM_DENY_3);
+                        pw.println("chain:" + (chainEnabled ? "enabled" : "disabled"));
+                        return 0;
+                    }
+                    case "set-package-networking-enabled": {
+                        final Boolean enabled = parseBooleanArgument(getNextArg());
+                        final String packageName = getNextArg();
+                        if (null == enabled || null == packageName) {
+                            onHelp();
+                            return -1;
+                        }
+                        // Throws NameNotFound if the package doesn't exist.
+                        final int appId = setPackageFirewallRule(
+                                ConnectivityManager.FIREWALL_CHAIN_OEM_DENY_3,
+                                packageName, enabled ? FIREWALL_RULE_DEFAULT : FIREWALL_RULE_DENY);
+                        final String msg = (enabled ? "Enabled" : "Disabled")
+                                + " networking for " + packageName + ", appId " + appId;
+                        Log.i(TAG, msg);
+                        pw.println(msg);
+                        return 0;
+                    }
+                    case "get-package-networking-enabled": {
+                        final String packageName = getNextArg();
+                        final int rule = getPackageFirewallRule(
+                                ConnectivityManager.FIREWALL_CHAIN_OEM_DENY_3, packageName);
+                        if (FIREWALL_RULE_ALLOW == rule || FIREWALL_RULE_DEFAULT == rule) {
+                            pw.println(packageName + ":" + "allow");
+                        } else if (FIREWALL_RULE_DENY == rule) {
+                            pw.println(packageName + ":" + "deny");
+                        } else {
+                            throw new IllegalStateException("Unknown rule " + rule + " for package "
+                                    + packageName);
+                        }
+                        return 0;
+                    }
                     case "reevaluate":
                         // Usage : adb shell cmd connectivity reevaluate <netId>
                         // If netId is omitted, then reevaluate the default network
@@ -10664,6 +10980,17 @@
                         Log.d(TAG, "Reevaluating network " + nai.network);
                         reportNetworkConnectivity(nai.network, !nai.isValidated());
                         return 0;
+                    case "bpf-get-cgroup-program-id": {
+                        // Usage : adb shell cmd connectivity bpf-get-cgroup-program-id <type>
+                        // Get cgroup bpf program Id for the given type. See BpfUtils#getProgramId
+                        // for more detail.
+                        // If type can't be parsed, this throws NumberFormatException, which
+                        // is passed back to adb who prints it.
+                        final int type = Integer.parseInt(getNextArg());
+                        final int ret = BpfUtils.getProgramId(type);
+                        pw.println(ret);
+                        return 0;
+                    }
                     default:
                         return handleDefaultCommands(cmd);
                 }
@@ -10683,6 +11010,15 @@
             pw.println("    Turn airplane mode on or off.");
             pw.println("  airplane-mode");
             pw.println("    Get airplane mode.");
+            pw.println("  set-chain3-enabled [true|false]");
+            pw.println("    Enable or disable FIREWALL_CHAIN_OEM_DENY_3 for debugging.");
+            pw.println("  get-chain3-enabled");
+            pw.println("    Returns whether FIREWALL_CHAIN_OEM_DENY_3 is enabled.");
+            pw.println("  set-package-networking-enabled [true|false] [package name]");
+            pw.println("    Set the deny bit in FIREWALL_CHAIN_OEM_DENY_3 to package. This has\n"
+                    + "    no effect if the chain is disabled.");
+            pw.println("  get-package-networking-enabled [package name]");
+            pw.println("    Get the deny bit in FIREWALL_CHAIN_OEM_DENY_3 for package.");
         }
     }
 
@@ -11353,7 +11689,7 @@
         public void onInterfaceLinkStateChanged(@NonNull String iface, boolean up) {
             mHandler.post(() -> {
                 for (NetworkAgentInfo nai : mNetworkAgentInfos) {
-                    nai.clatd.interfaceLinkStateChanged(iface, up);
+                    nai.clatd.handleInterfaceLinkStateChanged(iface, up);
                 }
             });
         }
@@ -11362,7 +11698,7 @@
         public void onInterfaceRemoved(@NonNull String iface) {
             mHandler.post(() -> {
                 for (NetworkAgentInfo nai : mNetworkAgentInfos) {
-                    nai.clatd.interfaceRemoved(iface);
+                    nai.clatd.handleInterfaceRemoved(iface);
                 }
             });
         }
@@ -11386,7 +11722,8 @@
         // If there is no default network, default network is considered active to keep the existing
         // behavior. Initial value is used until first connect to the default network.
         private volatile boolean mIsDefaultNetworkActive = true;
-        private final ArrayMap<String, IdleTimerParams> mActiveIdleTimers = new ArrayMap<>();
+        // Key is netId. Value is configured idle timer information.
+        private final SparseArray<IdleTimerParams> mActiveIdleTimers = new SparseArray<>();
 
         private static class IdleTimerParams {
             public final int timeout;
@@ -11414,7 +11751,7 @@
 
         public void handleReportNetworkActivity(NetworkActivityParams activityParams) {
             ensureRunningOnConnectivityServiceThread();
-            if (mActiveIdleTimers.isEmpty()) {
+            if (mActiveIdleTimers.size() == 0) {
                 // This activity change is not for the current default network.
                 // This can happen if netd callback post activity change event message but
                 // the default network is lost before processing this message.
@@ -11490,6 +11827,7 @@
          */
         private boolean setupDataActivityTracking(NetworkAgentInfo networkAgent) {
             final String iface = networkAgent.linkProperties.getInterfaceName();
+            final int netId = networkAgent.network().netId;
 
             final int timeout;
             final int type;
@@ -11514,7 +11852,7 @@
 
             if (timeout > 0 && iface != null) {
                 try {
-                    mActiveIdleTimers.put(iface, new IdleTimerParams(timeout, type));
+                    mActiveIdleTimers.put(netId, new IdleTimerParams(timeout, type));
                     mNetd.idletimerAddInterface(iface, timeout, Integer.toString(type));
                     return true;
                 } catch (Exception e) {
@@ -11530,6 +11868,7 @@
          */
         private void removeDataActivityTracking(NetworkAgentInfo networkAgent) {
             final String iface = networkAgent.linkProperties.getInterfaceName();
+            final int netId = networkAgent.network().netId;
             final NetworkCapabilities caps = networkAgent.networkCapabilities;
 
             if (iface == null) return;
@@ -11545,11 +11884,12 @@
 
             try {
                 updateRadioPowerState(false /* isActive */, type);
-                final IdleTimerParams params = mActiveIdleTimers.remove(iface);
+                final IdleTimerParams params = mActiveIdleTimers.get(netId);
                 if (params == null) {
                     // IdleTimer is not added if the configured timeout is 0 or negative value
                     return;
                 }
+                mActiveIdleTimers.remove(netId);
                 // The call fails silently if no idle timer setup for this interface
                 mNetd.idletimerRemoveInterface(iface, params.timeout,
                         Integer.toString(params.transportType));
@@ -11620,9 +11960,9 @@
             pw.print("mIsDefaultNetworkActive="); pw.println(mIsDefaultNetworkActive);
             pw.println("Idle timers:");
             try {
-                for (Map.Entry<String, IdleTimerParams> ent : mActiveIdleTimers.entrySet()) {
-                    pw.print("  "); pw.print(ent.getKey()); pw.println(":");
-                    final IdleTimerParams params = ent.getValue();
+                for (int i = 0; i < mActiveIdleTimers.size(); i++) {
+                    pw.print("  "); pw.print(mActiveIdleTimers.keyAt(i)); pw.println(":");
+                    final IdleTimerParams params = mActiveIdleTimers.valueAt(i);
                     pw.print("    timeout="); pw.print(params.timeout);
                     pw.print(" type="); pw.println(params.transportType);
                 }
@@ -12388,6 +12728,27 @@
         }
     }
 
+    @TargetApi(Build.VERSION_CODES.TIRAMISU)
+    @Override
+    public void setDataSaverEnabled(final boolean enable) {
+        enforceNetworkStackOrSettingsPermission();
+        try {
+            final boolean ret = mNetd.bandwidthEnableDataSaver(enable);
+            if (!ret) {
+                throw new IllegalStateException("Error when changing iptables: " + enable);
+            }
+        } catch (RemoteException e) {
+            // Lack of permission or binder errors.
+            throw new IllegalStateException(e);
+        }
+
+        try {
+            mBpfNetMaps.setDataSaverEnabled(enable);
+        } catch (ServiceSpecificException | UnsupportedOperationException e) {
+            Log.e(TAG, "Failed to set data saver " + enable + " : " + e);
+        }
+    }
+
     @Override
     public void updateMeteredNetworkAllowList(final int uid, final boolean add) {
         enforceNetworkStackOrSettingsPermission();
@@ -12418,6 +12779,21 @@
         }
     }
 
+    private int setPackageFirewallRule(final int chain, final String packageName, final int rule)
+            throws PackageManager.NameNotFoundException {
+        final PackageManager pm = mContext.getPackageManager();
+        final int appId = UserHandle.getAppId(pm.getPackageUid(packageName, 0 /* flags */));
+        if (appId < Process.FIRST_APPLICATION_UID) {
+            throw new RuntimeException("Can't set package firewall rule for system app "
+                    + packageName + " with appId " + appId);
+        }
+        for (final UserHandle uh : mUserManager.getUserHandles(false /* excludeDying */)) {
+            final int uid = uh.getUid(appId);
+            setUidFirewallRule(chain, uid, rule);
+        }
+        return appId;
+    }
+
     @Override
     public void setUidFirewallRule(final int chain, final int uid, final int rule) {
         enforceNetworkStackOrSettingsPermission();
@@ -12436,6 +12812,13 @@
         }
     }
 
+    private int getPackageFirewallRule(final int chain, final String packageName)
+            throws PackageManager.NameNotFoundException {
+        final PackageManager pm = mContext.getPackageManager();
+        final int appId = UserHandle.getAppId(pm.getPackageUid(packageName, 0 /* flags */));
+        return getUidFirewallRule(chain, appId);
+    }
+
     @Override
     public int getUidFirewallRule(final int chain, final int uid) {
         enforceNetworkStackOrSettingsPermission();
@@ -12467,7 +12850,7 @@
 
     private void closeSocketsForFirewallChainLocked(final int chain)
             throws ErrnoException, SocketException, InterruptedIOException {
-        if (mBpfNetMaps.isFirewallAllowList(chain)) {
+        if (BpfNetMapsUtils.isFirewallAllowList(chain)) {
             // Allowlist means the firewall denies all by default, uids must be explicitly allowed
             // So, close all non-system socket owned by uids that are not explicitly allowed
             Set<Range<Integer>> ranges = new ArraySet<>();
@@ -12520,4 +12903,10 @@
         enforceNetworkStackPermission(mContext);
         return mCdmps;
     }
+
+    @Override
+    public IBinder getRoutingCoordinatorService() {
+        enforceNetworkStackPermission(mContext);
+        return mRoutingCoordinatorService;
+    }
 }
diff --git a/service/src/com/android/server/connectivity/AutomaticOnOffKeepaliveTracker.java b/service/src/com/android/server/connectivity/AutomaticOnOffKeepaliveTracker.java
index 3befcfa..bba132f 100644
--- a/service/src/com/android/server/connectivity/AutomaticOnOffKeepaliveTracker.java
+++ b/service/src/com/android/server/connectivity/AutomaticOnOffKeepaliveTracker.java
@@ -692,8 +692,10 @@
 
     /**
      * Dump AutomaticOnOffKeepaliveTracker state.
+     * This should be only be called in ConnectivityService handler thread.
      */
     public void dump(IndentingPrintWriter pw) {
+        ensureRunningOnHandlerThread();
         mKeepaliveTracker.dump(pw);
         // Reading DeviceConfig will check if the calling uid and calling package name are the same.
         // Clear calling identity to align the calling uid and package so that it won't fail if cts
@@ -712,6 +714,9 @@
         pw.increaseIndent();
         mEventLog.reverseDump(pw);
         pw.decreaseIndent();
+
+        pw.println();
+        mKeepaliveStatsTracker.dump(pw);
     }
 
     /**
@@ -895,7 +900,7 @@
         public FileDescriptor createConnectedNetlinkSocket()
                 throws ErrnoException, SocketException {
             final FileDescriptor fd = NetlinkUtils.createNetLinkInetDiagSocket();
-            NetlinkUtils.connectSocketToNetlink(fd);
+            NetlinkUtils.connectToKernel(fd);
             Os.setsockoptTimeval(fd, SOL_SOCKET, SO_SNDTIMEO,
                     StructTimeval.fromMillis(IO_TIMEOUT_MS));
             return fd;
@@ -974,7 +979,7 @@
          * @return whether the feature is enabled
          */
         public boolean isTetheringFeatureNotChickenedOut(@NonNull final String name) {
-            return DeviceConfigUtils.isTetheringFeatureNotChickenedOut(name);
+            return DeviceConfigUtils.isTetheringFeatureNotChickenedOut(mContext, name);
         }
 
         /**
diff --git a/service/src/com/android/server/connectivity/CarrierPrivilegeAuthenticator.java b/service/src/com/android/server/connectivity/CarrierPrivilegeAuthenticator.java
index 4325763..ab7b1a7 100644
--- a/service/src/com/android/server/connectivity/CarrierPrivilegeAuthenticator.java
+++ b/service/src/com/android/server/connectivity/CarrierPrivilegeAuthenticator.java
@@ -16,10 +16,12 @@
 
 package com.android.server.connectivity;
 
-import static android.net.NetworkCapabilities.NET_CAPABILITY_CBS;
 import static android.net.NetworkCapabilities.TRANSPORT_CELLULAR;
 
+import static com.android.server.connectivity.ConnectivityFlags.CARRIER_SERVICE_CHANGED_USE_CALLBACK;
+
 import android.annotation.NonNull;
+import android.annotation.Nullable;
 import android.content.BroadcastReceiver;
 import android.content.Context;
 import android.content.Intent;
@@ -35,6 +37,7 @@
 import android.telephony.SubscriptionManager;
 import android.telephony.TelephonyManager;
 import android.util.Log;
+import android.util.SparseIntArray;
 
 import com.android.internal.annotations.GuardedBy;
 import com.android.internal.annotations.VisibleForTesting;
@@ -43,6 +46,7 @@
 import com.android.networkstack.apishim.common.TelephonyManagerShim;
 import com.android.networkstack.apishim.common.TelephonyManagerShim.CarrierPrivilegesListenerShim;
 import com.android.networkstack.apishim.common.UnsupportedApiLevelException;
+import com.android.server.ConnectivityService;
 
 import java.util.ArrayList;
 import java.util.List;
@@ -54,7 +58,7 @@
  * carrier privileged app that provides the carrier config
  * @hide
  */
-public class CarrierPrivilegeAuthenticator extends BroadcastReceiver {
+public class CarrierPrivilegeAuthenticator {
     private static final String TAG = CarrierPrivilegeAuthenticator.class.getSimpleName();
     private static final boolean DBG = true;
 
@@ -63,100 +67,100 @@
     private final TelephonyManagerShim mTelephonyManagerShim;
     private final TelephonyManager mTelephonyManager;
     @GuardedBy("mLock")
-    private int[] mCarrierServiceUid;
+    private final SparseIntArray mCarrierServiceUid = new SparseIntArray(2 /* initialCapacity */);
     @GuardedBy("mLock")
     private int mModemCount = 0;
     private final Object mLock = new Object();
-    private final HandlerThread mThread;
     private final Handler mHandler;
     @NonNull
-    private final List<CarrierPrivilegesListenerShim> mCarrierPrivilegesChangedListeners =
-            new ArrayList<>();
+    private final List<PrivilegeListener> mCarrierPrivilegesChangedListeners = new ArrayList<>();
+    private final boolean mUseCallbacksForServiceChanged;
 
     public CarrierPrivilegeAuthenticator(@NonNull final Context c,
+            @NonNull final ConnectivityService.Dependencies deps,
             @NonNull final TelephonyManager t,
-            @NonNull final TelephonyManagerShimImpl telephonyManagerShim) {
+            @NonNull final TelephonyManagerShim telephonyManagerShim) {
         mContext = c;
         mTelephonyManager = t;
         mTelephonyManagerShim = telephonyManagerShim;
-        mThread = new HandlerThread(TAG);
-        mThread.start();
-        mHandler = new Handler(mThread.getLooper()) {};
+        final HandlerThread thread = new HandlerThread(TAG);
+        thread.start();
+        mHandler = new Handler(thread.getLooper());
+        mUseCallbacksForServiceChanged = deps.isFeatureEnabled(
+                c, CARRIER_SERVICE_CHANGED_USE_CALLBACK);
+        final IntentFilter filter = new IntentFilter();
+        filter.addAction(TelephonyManager.ACTION_MULTI_SIM_CONFIG_CHANGED);
         synchronized (mLock) {
-            mModemCount = mTelephonyManager.getActiveModemCount();
-            registerForCarrierChanges();
-            updateCarrierServiceUid();
+            // 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());
+                    }
+                }
+            }, filter, null, mHandler);
+            simConfigChanged();
         }
     }
 
     public CarrierPrivilegeAuthenticator(@NonNull final Context c,
+            @NonNull final ConnectivityService.Dependencies deps,
             @NonNull final TelephonyManager t) {
-        mContext = c;
-        mTelephonyManager = t;
-        mTelephonyManagerShim = TelephonyManagerShimImpl.newInstance(mTelephonyManager);
-        mThread = new HandlerThread(TAG);
-        mThread.start();
-        mHandler = new Handler(mThread.getLooper()) {};
+        this(c, deps, t, TelephonyManagerShimImpl.newInstance(t));
+    }
+
+    private void simConfigChanged() {
         synchronized (mLock) {
+            unregisterCarrierPrivilegesListeners();
             mModemCount = mTelephonyManager.getActiveModemCount();
-            registerForCarrierChanges();
+            registerCarrierPrivilegesListeners(mModemCount);
+            if (!mUseCallbacksForServiceChanged) updateCarrierServiceUid();
+        }
+    }
+
+    private class PrivilegeListener implements CarrierPrivilegesListenerShim {
+        public final int mLogicalSlot;
+        PrivilegeListener(final int logicalSlot) {
+            mLogicalSlot = logicalSlot;
+        }
+
+        @Override public void onCarrierPrivilegesChanged(
+                @NonNull List<String> privilegedPackageNames,
+                @NonNull int[] privilegedUids) {
+            if (mUseCallbacksForServiceChanged) return;
+            // Re-trigger the synchronous check (which is also very cheap due
+            // to caching in CarrierPrivilegesTracker). This allows consistency
+            // with the onSubscriptionsChangedListener and broadcasts.
             updateCarrierServiceUid();
         }
-    }
 
-    /**
-     * Broadcast receiver for ACTION_MULTI_SIM_CONFIG_CHANGED
-     *
-     * <p>The broadcast receiver is registered with mHandler
-     */
-    @Override
-    public void onReceive(Context context, Intent intent) {
-        switch (intent.getAction()) {
-            case TelephonyManager.ACTION_MULTI_SIM_CONFIG_CHANGED:
-                handleActionMultiSimConfigChanged(context, intent);
-                break;
-            default:
-                Log.d(TAG, "Unknown intent received with action: " + intent.getAction());
+        @Override
+        public void onCarrierServiceChanged(@Nullable final String carrierServicePackageName,
+                final int carrierServiceUid) {
+            if (!mUseCallbacksForServiceChanged) {
+                // Re-trigger the synchronous check (which is also very cheap due
+                // to caching in CarrierPrivilegesTracker). This allows consistency
+                // with the onSubscriptionsChangedListener and broadcasts.
+                updateCarrierServiceUid();
+                return;
+            }
+            synchronized (mLock) {
+                mCarrierServiceUid.put(mLogicalSlot, carrierServiceUid);
+            }
         }
     }
 
-    private void handleActionMultiSimConfigChanged(Context context, Intent intent) {
-        unregisterCarrierPrivilegesListeners();
-        synchronized (mLock) {
-            mModemCount = mTelephonyManager.getActiveModemCount();
-        }
-        registerCarrierPrivilegesListeners();
-        updateCarrierServiceUid();
-    }
-
-    private void registerForCarrierChanges() {
-        final IntentFilter filter = new IntentFilter();
-        filter.addAction(TelephonyManager.ACTION_MULTI_SIM_CONFIG_CHANGED);
-        mContext.registerReceiver(this, filter, null, mHandler);
-        registerCarrierPrivilegesListeners();
-    }
-
-    private void registerCarrierPrivilegesListeners() {
+    private void registerCarrierPrivilegesListeners(final int modemCount) {
         final HandlerExecutor executor = new HandlerExecutor(mHandler);
-        int modemCount;
-        synchronized (mLock) {
-            modemCount = mModemCount;
-        }
         try {
             for (int i = 0; i < modemCount; i++) {
-                CarrierPrivilegesListenerShim carrierPrivilegesListener =
-                        new CarrierPrivilegesListenerShim() {
-                            @Override
-                            public void onCarrierPrivilegesChanged(
-                                    @NonNull List<String> privilegedPackageNames,
-                                    @NonNull int[] privilegedUids) {
-                                // Re-trigger the synchronous check (which is also very cheap due
-                                // to caching in CarrierPrivilegesTracker). This allows consistency
-                                // with the onSubscriptionsChangedListener and broadcasts.
-                                updateCarrierServiceUid();
-                            }
-                        };
-                addCarrierPrivilegesListener(i, executor, carrierPrivilegesListener);
+                PrivilegeListener carrierPrivilegesListener = new PrivilegeListener(i);
+                addCarrierPrivilegesListener(executor, carrierPrivilegesListener);
                 mCarrierPrivilegesChangedListeners.add(carrierPrivilegesListener);
             }
         } catch (IllegalArgumentException e) {
@@ -164,24 +168,13 @@
         }
     }
 
-    private void addCarrierPrivilegesListener(int logicalSlotIndex, Executor executor,
-            CarrierPrivilegesListenerShim listener) {
-        try {
-            mTelephonyManagerShim.addCarrierPrivilegesListener(
-                    logicalSlotIndex, executor, listener);
-        } catch (UnsupportedApiLevelException unsupportedApiLevelException) {
-            // Should not happen since CarrierPrivilegeAuthenticator is only used on T+
-            Log.e(TAG, "addCarrierPrivilegesListener API is not available");
+    @GuardedBy("mLock")
+    private void unregisterCarrierPrivilegesListeners() {
+        for (PrivilegeListener carrierPrivilegesListener : mCarrierPrivilegesChangedListeners) {
+            removeCarrierPrivilegesListener(carrierPrivilegesListener);
+            mCarrierServiceUid.delete(carrierPrivilegesListener.mLogicalSlot);
         }
-    }
-
-    private void removeCarrierPrivilegesListener(CarrierPrivilegesListenerShim listener) {
-        try {
-            mTelephonyManagerShim.removeCarrierPrivilegesListener(listener);
-        } catch (UnsupportedApiLevelException unsupportedApiLevelException) {
-            // Should not happen since CarrierPrivilegeAuthenticator is only used on T+
-            Log.e(TAG, "removeCarrierPrivilegesListener API is not available");
-        }
+        mCarrierPrivilegesChangedListeners.clear();
     }
 
     private String getCarrierServicePackageNameForLogicalSlot(int logicalSlotIndex) {
@@ -195,14 +188,6 @@
         return null;
     }
 
-    private void unregisterCarrierPrivilegesListeners() {
-        for (CarrierPrivilegesListenerShim carrierPrivilegesListener :
-                mCarrierPrivilegesChangedListeners) {
-            removeCarrierPrivilegesListener(carrierPrivilegesListener);
-        }
-        mCarrierPrivilegesChangedListeners.clear();
-    }
-
     /**
      * Check if a UID is the carrier service app of the subscription ID in the provided capabilities
      *
@@ -233,9 +218,9 @@
     @VisibleForTesting
     void updateCarrierServiceUid() {
         synchronized (mLock) {
-            mCarrierServiceUid = new int[mModemCount];
+            mCarrierServiceUid.clear();
             for (int i = 0; i < mModemCount; i++) {
-                mCarrierServiceUid[i] = getCarrierServicePackageUidForSlot(i);
+                mCarrierServiceUid.put(i, getCarrierServicePackageUidForSlot(i));
             }
         }
     }
@@ -244,11 +229,8 @@
     int getCarrierServiceUidForSubId(int subId) {
         final int slotId = getSlotIndex(subId);
         synchronized (mLock) {
-            if (slotId != SubscriptionManager.INVALID_SIM_SLOT_INDEX && slotId < mModemCount) {
-                return mCarrierServiceUid[slotId];
-            }
+            return mCarrierServiceUid.get(slotId, Process.INVALID_UID);
         }
-        return Process.INVALID_UID;
     }
 
     @VisibleForTesting
@@ -288,4 +270,26 @@
     int getCarrierServicePackageUidForSlot(int slotId) {
         return getUidForPackage(getCarrierServicePackageNameForLogicalSlot(slotId));
     }
+
+    // Helper methods to avoid having to deal with UnsupportedApiLevelException.
+
+    private void addCarrierPrivilegesListener(@NonNull final Executor executor,
+            @NonNull final PrivilegeListener listener) {
+        try {
+            mTelephonyManagerShim.addCarrierPrivilegesListener(listener.mLogicalSlot, executor,
+                    listener);
+        } catch (UnsupportedApiLevelException unsupportedApiLevelException) {
+            // Should not happen since CarrierPrivilegeAuthenticator is only used on T+
+            Log.e(TAG, "addCarrierPrivilegesListener API is not available");
+        }
+    }
+
+    private void removeCarrierPrivilegesListener(PrivilegeListener listener) {
+        try {
+            mTelephonyManagerShim.removeCarrierPrivilegesListener(listener);
+        } catch (UnsupportedApiLevelException unsupportedApiLevelException) {
+            // Should not happen since CarrierPrivilegeAuthenticator is only used on T+
+            Log.e(TAG, "removeCarrierPrivilegesListener API is not available");
+        }
+    }
 }
diff --git a/service/src/com/android/server/connectivity/ClatCoordinator.java b/service/src/com/android/server/connectivity/ClatCoordinator.java
index eb3e7ce..17de146 100644
--- a/service/src/com/android/server/connectivity/ClatCoordinator.java
+++ b/service/src/com/android/server/connectivity/ClatCoordinator.java
@@ -78,7 +78,7 @@
     @VisibleForTesting
     static final int MTU_DELTA = 28;
     @VisibleForTesting
-    static final int CLAT_MAX_MTU = 65536;
+    static final int CLAT_MAX_MTU = 1500 + MTU_DELTA;
 
     // This must match the interface prefix in clatd.c.
     private static final String CLAT_PREFIX = "v4-";
@@ -673,7 +673,7 @@
             throw new IOException("Detect MTU on " + tunIface + " failed: " + e);
         }
         final int mtu = adjustMtu(detectedMtu);
-        Log.i(TAG, "ipv4 mtu is " + mtu);
+        Log.i(TAG, "detected ipv4 mtu of " + detectedMtu + " adjusted to " + mtu);
 
         // Config tun interface mtu, address and bring up.
         try {
diff --git a/service/src/com/android/server/connectivity/ConnectivityFlags.java b/service/src/com/android/server/connectivity/ConnectivityFlags.java
index 9039a14..5aac8f1 100644
--- a/service/src/com/android/server/connectivity/ConnectivityFlags.java
+++ b/service/src/com/android/server/connectivity/ConnectivityFlags.java
@@ -33,6 +33,10 @@
     public static final String NO_REMATCH_ALL_REQUESTS_ON_REGISTER =
             "no_rematch_all_requests_on_register";
 
+    @VisibleForTesting
+    public static final String CARRIER_SERVICE_CHANGED_USE_CALLBACK =
+            "carrier_service_changed_use_callback_version";
+
     private boolean mNoRematchAllRequestsOnRegister;
 
     /**
diff --git a/service/src/com/android/server/connectivity/ConnectivityNativeService.java b/service/src/com/android/server/connectivity/ConnectivityNativeService.java
index c1ba40e..cf6127f 100644
--- a/service/src/com/android/server/connectivity/ConnectivityNativeService.java
+++ b/service/src/com/android/server/connectivity/ConnectivityNativeService.java
@@ -16,9 +16,6 @@
 
 package com.android.server.connectivity;
 
-import static com.android.net.module.util.BpfUtils.BPF_CGROUP_INET4_BIND;
-import static com.android.net.module.util.BpfUtils.BPF_CGROUP_INET6_BIND;
-
 import android.annotation.NonNull;
 import android.annotation.Nullable;
 import android.content.Context;
@@ -31,11 +28,9 @@
 
 import com.android.internal.annotations.VisibleForTesting;
 import com.android.net.module.util.BpfBitmap;
-import com.android.net.module.util.BpfUtils;
 import com.android.net.module.util.CollectionUtils;
 import com.android.net.module.util.PermissionUtils;
 
-import java.io.IOException;
 import java.util.ArrayList;
 
 /**
@@ -45,11 +40,7 @@
     public static final String SERVICE_NAME = "connectivity_native";
 
     private static final String TAG = ConnectivityNativeService.class.getSimpleName();
-    private static final String CGROUP_PATH = "/sys/fs/cgroup";
-    private static final String V4_PROG_PATH =
-            "/sys/fs/bpf/net_shared/prog_block_bind4_block_port";
-    private static final String V6_PROG_PATH =
-            "/sys/fs/bpf/net_shared/prog_block_bind6_block_port";
+
     private static final String BLOCKED_PORTS_MAP_PATH =
             "/sys/fs/bpf/net_shared/map_block_blocked_ports_map";
 
@@ -95,7 +86,6 @@
     protected ConnectivityNativeService(final Context context, @NonNull Dependencies deps) {
         mContext = context;
         mBpfBlockedPortsMap = deps.getBlockPortsMap();
-        attachProgram();
     }
 
     @Override
@@ -155,23 +145,4 @@
     public String getInterfaceHash() {
         return this.HASH;
     }
-
-    /**
-     * Attach BPF program
-     */
-    private void attachProgram() {
-        try {
-            BpfUtils.attachProgram(BPF_CGROUP_INET4_BIND, V4_PROG_PATH, CGROUP_PATH, 0);
-        } catch (IOException e) {
-            throw new UnsupportedOperationException("Unable to attach to BPF_CGROUP_INET4_BIND: "
-                    + e);
-        }
-        try {
-            BpfUtils.attachProgram(BPF_CGROUP_INET6_BIND, V6_PROG_PATH, CGROUP_PATH, 0);
-        } catch (IOException e) {
-            throw new UnsupportedOperationException("Unable to attach to BPF_CGROUP_INET6_BIND: "
-                    + e);
-        }
-        Log.d(TAG, "Attached BPF_CGROUP_INET4_BIND and BPF_CGROUP_INET6_BIND programs");
-    }
 }
diff --git a/service/src/com/android/server/connectivity/DnsManager.java b/service/src/com/android/server/connectivity/DnsManager.java
index 1493cae..894bcc4 100644
--- a/service/src/com/android/server/connectivity/DnsManager.java
+++ b/service/src/com/android/server/connectivity/DnsManager.java
@@ -38,6 +38,7 @@
 import android.net.InetAddresses;
 import android.net.LinkProperties;
 import android.net.Network;
+import android.net.NetworkCapabilities;
 import android.net.ResolverParamsParcel;
 import android.net.Uri;
 import android.net.shared.PrivateDnsConfig;
@@ -251,7 +252,7 @@
     // TODO: Replace the Map with SparseArrays.
     private final Map<Integer, PrivateDnsValidationStatuses> mPrivateDnsValidationMap;
     private final Map<Integer, LinkProperties> mLinkPropertiesMap;
-    private final Map<Integer, int[]> mTransportsMap;
+    private final Map<Integer, NetworkCapabilities> mNetworkCapabilitiesMap;
 
     private int mSampleValidity;
     private int mSuccessThreshold;
@@ -265,7 +266,7 @@
         mPrivateDnsMap = new ConcurrentHashMap<>();
         mPrivateDnsValidationMap = new HashMap<>();
         mLinkPropertiesMap = new HashMap<>();
-        mTransportsMap = new HashMap<>();
+        mNetworkCapabilitiesMap = new HashMap<>();
 
         // TODO: Create and register ContentObservers to track every setting
         // used herein, posting messages to respond to changes.
@@ -278,7 +279,7 @@
     public void removeNetwork(Network network) {
         mPrivateDnsMap.remove(network.getNetId());
         mPrivateDnsValidationMap.remove(network.getNetId());
-        mTransportsMap.remove(network.getNetId());
+        mNetworkCapabilitiesMap.remove(network.getNetId());
         mLinkPropertiesMap.remove(network.getNetId());
     }
 
@@ -325,13 +326,17 @@
     }
 
     /**
-     * When creating a new network or transport types are changed in a specific network,
-     * transport types are always saved to a hashMap before update dns config.
-     * When destroying network, the specific network will be removed from the hashMap.
-     * The hashMap is always accessed on the same thread.
+     * Update {@link NetworkCapabilities} stored in this instance.
+     *
+     * In order to ensure that the resolver has access to necessary information when other events
+     * occur, capabilities are always saved to a hashMap before updating the DNS configuration
+     * whenever a new network is created, transport types are modified, or metered capabilities are
+     * altered for a network. When a network is destroyed, the corresponding entry is removed from
+     * the hashMap. To prevent concurrency issues, the hashMap should always be accessed from the
+     * same thread.
      */
-    public void updateTransportsForNetwork(int netId, @NonNull int[] transportTypes) {
-        mTransportsMap.put(netId, transportTypes);
+    public void updateCapabilitiesForNetwork(int netId, @NonNull final NetworkCapabilities nc) {
+        mNetworkCapabilitiesMap.put(netId, nc);
         sendDnsConfigurationForNetwork(netId);
     }
 
@@ -351,8 +356,8 @@
      */
     public void sendDnsConfigurationForNetwork(int netId) {
         final LinkProperties lp = mLinkPropertiesMap.get(netId);
-        final int[] transportTypes = mTransportsMap.get(netId);
-        if (lp == null || transportTypes == null) return;
+        final NetworkCapabilities nc = mNetworkCapabilitiesMap.get(netId);
+        if (lp == null || nc == null) return;
         updateParametersSettings();
         final ResolverParamsParcel paramsParcel = new ResolverParamsParcel();
 
@@ -383,7 +388,8 @@
                               .collect(Collectors.toList()))
                 : useTls ? paramsParcel.servers  // Opportunistic
                 : new String[0];            // Off
-        paramsParcel.transportTypes = transportTypes;
+        paramsParcel.transportTypes = nc.getTransportTypes();
+        paramsParcel.meteredNetwork = nc.isMetered();
         // Prepare to track the validation status of the DNS servers in the
         // resolver config when private DNS is in opportunistic or strict mode.
         if (useTls) {
@@ -397,12 +403,13 @@
         }
 
         Log.d(TAG, String.format("sendDnsConfigurationForNetwork(%d, %s, %s, %d, %d, %d, %d, "
-                + "%d, %d, %s, %s)", paramsParcel.netId, Arrays.toString(paramsParcel.servers),
-                Arrays.toString(paramsParcel.domains), paramsParcel.sampleValiditySeconds,
-                paramsParcel.successThreshold, paramsParcel.minSamples,
-                paramsParcel.maxSamples, paramsParcel.baseTimeoutMsec,
+                + "%d, %d, %s, %s, %s, %b)", paramsParcel.netId,
+                Arrays.toString(paramsParcel.servers), Arrays.toString(paramsParcel.domains),
+                paramsParcel.sampleValiditySeconds, paramsParcel.successThreshold,
+                paramsParcel.minSamples, paramsParcel.maxSamples, paramsParcel.baseTimeoutMsec,
                 paramsParcel.retryCount, paramsParcel.tlsName,
-                Arrays.toString(paramsParcel.tlsServers)));
+                Arrays.toString(paramsParcel.tlsServers),
+                Arrays.toString(paramsParcel.transportTypes), paramsParcel.meteredNetwork));
 
         try {
             mDnsResolver.setResolverConfiguration(paramsParcel);
diff --git a/service/src/com/android/server/connectivity/HandlerUtils.java b/service/src/com/android/server/connectivity/HandlerUtils.java
new file mode 100644
index 0000000..997ecbf
--- /dev/null
+++ b/service/src/com/android/server/connectivity/HandlerUtils.java
@@ -0,0 +1,139 @@
+/*
+ * 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.connectivity;
+
+import android.annotation.NonNull;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.SystemClock;
+
+/**
+ * Helper class for Handler related utilities.
+ *
+ * @hide
+ */
+public class HandlerUtils {
+    // Note: @hide methods copied from android.os.Handler
+    /**
+     * Runs the specified task synchronously.
+     * <p>
+     * If the current thread is the same as the handler thread, then the runnable
+     * runs immediately without being enqueued.  Otherwise, posts the runnable
+     * to the handler and waits for it to complete before returning.
+     * </p><p>
+     * This method is dangerous!  Improper use can result in deadlocks.
+     * Never call this method while any locks are held or use it in a
+     * possibly re-entrant manner.
+     * </p><p>
+     * This method is occasionally useful in situations where a background thread
+     * must synchronously await completion of a task that must run on the
+     * handler's thread.  However, this problem is often a symptom of bad design.
+     * Consider improving the design (if possible) before resorting to this method.
+     * </p><p>
+     * One example of where you might want to use this method is when you just
+     * set up a Handler thread and need to perform some initialization steps on
+     * it before continuing execution.
+     * </p><p>
+     * If timeout occurs then this method returns <code>false</code> but the runnable
+     * will remain posted on the handler and may already be in progress or
+     * complete at a later time.
+     * </p><p>
+     * When using this method, be sure to use {@link Looper#quitSafely} when
+     * quitting the looper.  Otherwise {@link #runWithScissors} may hang indefinitely.
+     * (TODO: We should fix this by making MessageQueue aware of blocking runnables.)
+     * </p>
+     *
+     * @param h The target handler.
+     * @param r The Runnable that will be executed synchronously.
+     * @param timeout The timeout in milliseconds, or 0 to wait indefinitely.
+     *
+     * @return Returns true if the Runnable was successfully executed.
+     *         Returns false on failure, usually because the
+     *         looper processing the message queue is exiting.
+     *
+     * @hide This method is prone to abuse and should probably not be in the API.
+     * If we ever do make it part of the API, we might want to rename it to something
+     * less funny like runUnsafe().
+     */
+    public static boolean runWithScissors(@NonNull Handler h, @NonNull Runnable r, long timeout) {
+        if (r == null) {
+            throw new IllegalArgumentException("runnable must not be null");
+        }
+        if (timeout < 0) {
+            throw new IllegalArgumentException("timeout must be non-negative");
+        }
+
+        if (Looper.myLooper() == h.getLooper()) {
+            r.run();
+            return true;
+        }
+
+        BlockingRunnable br = new BlockingRunnable(r);
+        return br.postAndWait(h, timeout);
+    }
+
+    private static final class BlockingRunnable implements Runnable {
+        private final Runnable mTask;
+        private boolean mDone;
+
+        BlockingRunnable(Runnable task) {
+            mTask = task;
+        }
+
+        @Override
+        public void run() {
+            try {
+                mTask.run();
+            } finally {
+                synchronized (this) {
+                    mDone = true;
+                    notifyAll();
+                }
+            }
+        }
+
+        public boolean postAndWait(Handler handler, long timeout) {
+            if (!handler.post(this)) {
+                return false;
+            }
+
+            synchronized (this) {
+                if (timeout > 0) {
+                    final long expirationTime = SystemClock.uptimeMillis() + timeout;
+                    while (!mDone) {
+                        long delay = expirationTime - SystemClock.uptimeMillis();
+                        if (delay <= 0) {
+                            return false; // timeout
+                        }
+                        try {
+                            wait(delay);
+                        } catch (InterruptedException ex) {
+                        }
+                    }
+                } else {
+                    while (!mDone) {
+                        try {
+                            wait();
+                        } catch (InterruptedException ex) {
+                        }
+                    }
+                }
+            }
+            return true;
+        }
+    }
+}
diff --git a/service/src/com/android/server/connectivity/KeepaliveStatsTracker.java b/service/src/com/android/server/connectivity/KeepaliveStatsTracker.java
index 0c2ed18..7a8b41b 100644
--- a/service/src/com/android/server/connectivity/KeepaliveStatsTracker.java
+++ b/service/src/com/android/server/connectivity/KeepaliveStatsTracker.java
@@ -34,6 +34,7 @@
 import android.telephony.SubscriptionInfo;
 import android.telephony.SubscriptionManager;
 import android.telephony.TelephonyManager;
+import android.util.IndentingPrintWriter;
 import android.util.Log;
 import android.util.SparseArray;
 import android.util.SparseIntArray;
@@ -73,6 +74,9 @@
 public class KeepaliveStatsTracker {
     private static final String TAG = KeepaliveStatsTracker.class.getSimpleName();
     private static final int INVALID_KEEPALIVE_ID = -1;
+    // 1 hour acceptable deviation in metrics collection duration time.
+    private static final long MAX_EXPECTED_DURATION_MS =
+            AutomaticOnOffKeepaliveTracker.METRICS_COLLECTION_DURATION_MS + 1 * 60 * 60 * 1_000L;
 
     @NonNull private final Handler mConnectivityServiceHandler;
     @NonNull private final Dependencies mDependencies;
@@ -709,6 +713,36 @@
         return mEnabled.get();
     }
 
+    /**
+     * Checks the DailykeepaliveInfoReported for the following:
+     * 1. total active durations/lifetimes <= total registered durations/lifetimes.
+     * 2. Total time in Durations == total time in Carrier lifetime stats
+     * 3. The total elapsed real time spent is within expectations.
+     */
+    @VisibleForTesting
+    public boolean allMetricsExpected(DailykeepaliveInfoReported dailyKeepaliveInfoReported) {
+        int totalRegistered = 0;
+        int totalActiveDurations = 0;
+        int totalTimeSpent = 0;
+        for (DurationForNumOfKeepalive durationForNumOfKeepalive: dailyKeepaliveInfoReported
+                .getDurationPerNumOfKeepalive().getDurationForNumOfKeepaliveList()) {
+            final int n = durationForNumOfKeepalive.getNumOfKeepalive();
+            totalRegistered += durationForNumOfKeepalive.getKeepaliveRegisteredDurationsMsec() * n;
+            totalActiveDurations += durationForNumOfKeepalive.getKeepaliveActiveDurationsMsec() * n;
+            totalTimeSpent += durationForNumOfKeepalive.getKeepaliveRegisteredDurationsMsec();
+        }
+        int totalLifetimes = 0;
+        int totalActiveLifetimes = 0;
+        for (KeepaliveLifetimeForCarrier keepaliveLifetimeForCarrier: dailyKeepaliveInfoReported
+                .getKeepaliveLifetimePerCarrier().getKeepaliveLifetimeForCarrierList()) {
+            totalLifetimes += keepaliveLifetimeForCarrier.getLifetimeMsec();
+            totalActiveLifetimes += keepaliveLifetimeForCarrier.getActiveLifetimeMsec();
+        }
+        return totalActiveDurations <= totalRegistered && totalActiveLifetimes <= totalLifetimes
+                && totalLifetimes == totalRegistered && totalActiveLifetimes == totalActiveDurations
+                && totalTimeSpent <= MAX_EXPECTED_DURATION_MS;
+    }
+
     /** Writes the stored metrics to ConnectivityStatsLog and resets. */
     public void writeAndResetMetrics() {
         ensureRunningOnHandlerThread();
@@ -724,9 +758,21 @@
         }
 
         final DailykeepaliveInfoReported dailyKeepaliveInfoReported = buildAndResetMetrics();
+        if (!allMetricsExpected(dailyKeepaliveInfoReported)) {
+            Log.wtf(TAG, "Unexpected metrics values: " + dailyKeepaliveInfoReported.toString());
+        }
         mDependencies.writeStats(dailyKeepaliveInfoReported);
     }
 
+    /** Dump KeepaliveStatsTracker state. */
+    public void dump(IndentingPrintWriter pw) {
+        ensureRunningOnHandlerThread();
+        pw.println("KeepaliveStatsTracker enabled: " + isEnabled());
+        pw.increaseIndent();
+        pw.println(buildKeepaliveMetrics().toString());
+        pw.decreaseIndent();
+    }
+
     private void ensureRunningOnHandlerThread() {
         if (mConnectivityServiceHandler.getLooper().getThread() != Thread.currentThread()) {
             throw new IllegalStateException(
diff --git a/service/src/com/android/server/connectivity/KeepaliveTracker.java b/service/src/com/android/server/connectivity/KeepaliveTracker.java
index feba821..a51f09f 100644
--- a/service/src/com/android/server/connectivity/KeepaliveTracker.java
+++ b/service/src/com/android/server/connectivity/KeepaliveTracker.java
@@ -993,7 +993,7 @@
          */
         public boolean isAddressTranslationEnabled(@NonNull Context context) {
             return DeviceConfigUtils.isFeatureSupported(context, FEATURE_CLAT_ADDRESS_TRANSLATE)
-                    && DeviceConfigUtils.isTetheringFeatureNotChickenedOut(
+                    && DeviceConfigUtils.isTetheringFeatureNotChickenedOut(context,
                             CONFIG_DISABLE_CLAT_ADDRESS_TRANSLATE);
         }
     }
diff --git a/service/src/com/android/server/connectivity/Nat464Xlat.java b/service/src/com/android/server/connectivity/Nat464Xlat.java
index f9e07fd..065922d 100644
--- a/service/src/com/android/server/connectivity/Nat464Xlat.java
+++ b/service/src/com/android/server/connectivity/Nat464Xlat.java
@@ -483,8 +483,9 @@
 
     /**
      * Adds stacked link on base link and transitions to RUNNING state.
+     * Must be called on the handler thread.
      */
-    private void handleInterfaceLinkStateChanged(String iface, boolean up) {
+    public void handleInterfaceLinkStateChanged(String iface, boolean up) {
         // TODO: if we call start(), then stop(), then start() again, and the
         // interfaceLinkStateChanged notification for the first start is delayed past the first
         // stop, then the code becomes out of sync with system state and will behave incorrectly.
@@ -499,6 +500,7 @@
         // Once this code is converted to StateMachine, it will be possible to use deferMessage to
         // ensure it stays in STARTING state until the interfaceLinkStateChanged notification fires,
         // and possibly use a timeout (or provide some guarantees at the lower layer) to address #1.
+        ensureRunningOnHandlerThread();
         if (!isStarting() || !up || !Objects.equals(mIface, iface)) {
             return;
         }
@@ -519,8 +521,10 @@
 
     /**
      * Removes stacked link on base link and transitions to IDLE state.
+     * Must be called on the handler thread.
      */
-    private void handleInterfaceRemoved(String iface) {
+    public void handleInterfaceRemoved(String iface) {
+        ensureRunningOnHandlerThread();
         if (!Objects.equals(mIface, iface)) {
             return;
         }
@@ -536,14 +540,6 @@
         stop();
     }
 
-    public void interfaceLinkStateChanged(String iface, boolean up) {
-        mNetwork.handler().post(() -> { handleInterfaceLinkStateChanged(iface, up); });
-    }
-
-    public void interfaceRemoved(String iface) {
-        mNetwork.handler().post(() -> handleInterfaceRemoved(iface));
-    }
-
     /**
      * Translate the input v4 address to v6 clat address.
      */
diff --git a/service/src/com/android/server/connectivity/NetworkAgentInfo.java b/service/src/com/android/server/connectivity/NetworkAgentInfo.java
index bdd841f..7cd3cc8 100644
--- a/service/src/com/android/server/connectivity/NetworkAgentInfo.java
+++ b/service/src/com/android/server/connectivity/NetworkAgentInfo.java
@@ -17,6 +17,7 @@
 package com.android.server.connectivity;
 
 import static android.net.ConnectivityDiagnosticsManager.ConnectivityReport;
+import static android.net.NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED;
 import static android.net.NetworkCapabilities.TRANSPORT_CELLULAR;
 import static android.net.NetworkCapabilities.TRANSPORT_ETHERNET;
@@ -35,6 +36,7 @@
 import android.net.INetworkAgentRegistry;
 import android.net.INetworkMonitor;
 import android.net.LinkProperties;
+import android.net.LocalNetworkConfig;
 import android.net.NattKeepalivePacketData;
 import android.net.Network;
 import android.net.NetworkAgent;
@@ -64,7 +66,6 @@
 import com.android.internal.annotations.VisibleForTesting;
 import com.android.internal.util.IndentingPrintWriter;
 import com.android.internal.util.WakeupMessage;
-import com.android.modules.utils.build.SdkLevel;
 import com.android.server.ConnectivityService;
 
 import java.io.PrintWriter;
@@ -174,6 +175,7 @@
     // TODO: make this private with a getter.
     @NonNull public NetworkCapabilities networkCapabilities;
     @NonNull public final NetworkAgentConfig networkAgentConfig;
+    @Nullable public LocalNetworkConfig localNetworkConfig;
 
     // Underlying networks declared by the agent.
     // The networks in this list might be declared by a VPN using setUnderlyingNetworks and are
@@ -427,12 +429,28 @@
     private final boolean mHasAutomotiveFeature;
 
     /**
+     * Checks that a proposed update to the NCs of this NAI satisfies structural constraints.
+     *
+     * Some changes to NetworkCapabilities are structurally not supported by the stack, and
+     * NetworkAgents are absolutely never allowed to try and do them. When one of these is
+     * violated, this method returns false, which has ConnectivityService disconnect the network ;
+     * this is meant to guarantee that no implementor ever tries to do this.
+     */
+    public boolean respectsNcStructuralConstraints(@NonNull final NetworkCapabilities proposedNc) {
+        if (networkCapabilities.hasCapability(NET_CAPABILITY_LOCAL_NETWORK)
+                != proposedNc.hasCapability(NET_CAPABILITY_LOCAL_NETWORK)) {
+            return false;
+        }
+        return true;
+    }
+
+    /**
      * Sets the capabilities sent by the agent for later retrieval.
-     *
-     * This method does not sanitize the capabilities ; instead, use
-     * {@link #getDeclaredCapabilitiesSanitized} to retrieve a sanitized
-     * copy of the capabilities as they were passed here.
-     *
+     * <p>
+     * This method does not sanitize the capabilities before storing them ; instead, use
+     * {@link #getDeclaredCapabilitiesSanitized} to retrieve a sanitized copy of the capabilities
+     * as they were passed here.
+     * <p>
      * This method makes a defensive copy to avoid issues where the passed object is later mutated.
      *
      * @param caps the caps sent by the agent
@@ -453,6 +471,8 @@
      * apply to the allowedUids field.
      * They also should not mutate immutable capabilities, although for backward-compatibility
      * this is not enforced and limited to just a log.
+     * Forbidden capabilities also make no sense for networks, so they are disallowed and
+     * will be ignored with a warning.
      *
      * @param carrierPrivilegeAuthenticator the authenticator, to check access UIDs.
      */
@@ -461,14 +481,15 @@
         final NetworkCapabilities nc = new NetworkCapabilities(mDeclaredCapabilitiesUnsanitized);
         if (nc.hasConnectivityManagedCapability()) {
             Log.wtf(TAG, "BUG: " + this + " has CS-managed capability.");
+            nc.removeAllForbiddenCapabilities();
         }
         if (networkCapabilities.getOwnerUid() != nc.getOwnerUid()) {
             Log.e(TAG, toShortString() + ": ignoring attempt to change owner from "
                     + networkCapabilities.getOwnerUid() + " to " + nc.getOwnerUid());
             nc.setOwnerUid(networkCapabilities.getOwnerUid());
         }
-        restrictCapabilitiesFromNetworkAgent(
-                nc, creatorUid, mHasAutomotiveFeature, carrierPrivilegeAuthenticator);
+        restrictCapabilitiesFromNetworkAgent(nc, creatorUid, mHasAutomotiveFeature,
+                mConnServiceDeps, carrierPrivilegeAuthenticator);
         return nc;
     }
 
@@ -598,6 +619,7 @@
     private static final String TAG = ConnectivityService.class.getSimpleName();
     private static final boolean VDBG = false;
     private final ConnectivityService mConnService;
+    private final ConnectivityService.Dependencies mConnServiceDeps;
     private final Context mContext;
     private final Handler mHandler;
     private final QosCallbackTracker mQosCallbackTracker;
@@ -606,6 +628,7 @@
 
     public NetworkAgentInfo(INetworkAgent na, Network net, NetworkInfo info,
             @NonNull LinkProperties lp, @NonNull NetworkCapabilities nc,
+            @Nullable LocalNetworkConfig localNetworkConfig,
             @NonNull NetworkScore score, Context context,
             Handler handler, NetworkAgentConfig config, ConnectivityService connService, INetd netd,
             IDnsResolver dnsResolver, int factorySerialNumber, int creatorUid,
@@ -623,8 +646,10 @@
         networkInfo = info;
         linkProperties = lp;
         networkCapabilities = nc;
+        this.localNetworkConfig = localNetworkConfig;
         networkAgentConfig = config;
         mConnService = connService;
+        mConnServiceDeps = deps;
         setScore(score); // uses members connService, networkCapabilities and networkAgentConfig
         clatd = new Nat464Xlat(this, netd, dnsResolver, deps);
         mContext = context;
@@ -901,6 +926,12 @@
         }
 
         @Override
+        public void sendLocalNetworkConfig(@NonNull final LocalNetworkConfig config) {
+            mHandler.obtainMessage(NetworkAgent.EVENT_LOCAL_NETWORK_CONFIG_CHANGED,
+                    new Pair<>(NetworkAgentInfo.this, config)).sendToTarget();
+        }
+
+        @Override
         public void sendScore(@NonNull final NetworkScore score) {
             mHandler.obtainMessage(NetworkAgent.EVENT_NETWORK_SCORE_CHANGED,
                     new Pair<>(NetworkAgentInfo.this, score)).sendToTarget();
@@ -1227,6 +1258,11 @@
         return networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN);
     }
 
+    /** Whether this network is a local network */
+    public boolean isLocalNetwork() {
+        return networkCapabilities.hasCapability(NET_CAPABILITY_LOCAL_NETWORK);
+    }
+
     /**
      * Whether this network should propagate the capabilities from its underlying networks.
      * Currently only true for VPNs.
@@ -1515,23 +1551,26 @@
      */
     public static void restrictCapabilitiesFromNetworkAgent(@NonNull final NetworkCapabilities nc,
             final int creatorUid, final boolean hasAutomotiveFeature,
+            @NonNull final ConnectivityService.Dependencies deps,
             @Nullable final CarrierPrivilegeAuthenticator authenticator) {
         if (nc.hasTransport(TRANSPORT_TEST)) {
             nc.restrictCapabilitiesForTestNetwork(creatorUid);
         }
-        if (!areAllowedUidsAcceptableFromNetworkAgent(nc, hasAutomotiveFeature, authenticator)) {
+        if (!areAllowedUidsAcceptableFromNetworkAgent(
+                nc, hasAutomotiveFeature, deps, authenticator)) {
             nc.setAllowedUids(new ArraySet<>());
         }
     }
 
     private static boolean areAllowedUidsAcceptableFromNetworkAgent(
             @NonNull final NetworkCapabilities nc, final boolean hasAutomotiveFeature,
+            @NonNull final ConnectivityService.Dependencies deps,
             @Nullable final CarrierPrivilegeAuthenticator carrierPrivilegeAuthenticator) {
         // NCs without access UIDs are fine.
         if (!nc.hasAllowedUids()) return true;
         // S and below must never accept access UIDs, even if an agent sends them, because netd
         // didn't support the required feature in S.
-        if (!SdkLevel.isAtLeastT()) return false;
+        if (!deps.isAtLeastT()) return false;
 
         // On a non-restricted network, access UIDs make no sense
         if (nc.hasCapability(NET_CAPABILITY_NOT_RESTRICTED)) return false;
diff --git a/service/src/com/android/server/connectivity/NetworkDiagnostics.java b/service/src/com/android/server/connectivity/NetworkDiagnostics.java
index e1e2585..3db37e5 100644
--- a/service/src/com/android/server/connectivity/NetworkDiagnostics.java
+++ b/service/src/com/android/server/connectivity/NetworkDiagnostics.java
@@ -340,8 +340,9 @@
     @TargetApi(Build.VERSION_CODES.S)
     private int getMtuForTarget(InetAddress target) {
         final int family = target instanceof Inet4Address ? AF_INET : AF_INET6;
+        FileDescriptor socket = null;
         try {
-            final FileDescriptor socket = Os.socket(family, SOCK_DGRAM, 0);
+            socket = Os.socket(family, SOCK_DGRAM, 0);
             mNetwork.bindSocket(socket);
             Os.connect(socket, target, 0);
             if (family == AF_INET) {
@@ -352,6 +353,8 @@
         } catch (ErrnoException | IOException e) {
             Log.e(TAG, "Can't get MTU for destination " + target, e);
             return -1;
+        } finally {
+            IoUtils.closeQuietly(socket);
         }
     }
 
diff --git a/service/src/com/android/server/connectivity/NetworkRanker.java b/service/src/com/android/server/connectivity/NetworkRanker.java
index d94c8dc..c473444 100644
--- a/service/src/com/android/server/connectivity/NetworkRanker.java
+++ b/service/src/com/android/server/connectivity/NetworkRanker.java
@@ -17,6 +17,8 @@
 package com.android.server.connectivity;
 
 import static android.net.NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL;
+import static android.net.NetworkCapabilities.NET_CAPABILITY_PRIORITIZE_BANDWIDTH;
+import static android.net.NetworkCapabilities.NET_CAPABILITY_PRIORITIZE_LATENCY;
 import static android.net.NetworkCapabilities.TRANSPORT_BLUETOOTH;
 import static android.net.NetworkCapabilities.TRANSPORT_CELLULAR;
 import static android.net.NetworkCapabilities.TRANSPORT_ETHERNET;
@@ -221,6 +223,19 @@
     }
 
     /**
+     * Returns whether the scorable has any of the PRIORITIZE_* capabilities.
+     *
+     * These capabilities code for customer slices, and a network that has one is a customer slice.
+     */
+    private boolean hasPrioritizedCapability(@NonNull final Scoreable nai) {
+        final NetworkCapabilities caps = nai.getCapsNoCopy();
+        final long anyPrioritizeCapability =
+                (1L << NET_CAPABILITY_PRIORITIZE_LATENCY)
+                | (1L << NET_CAPABILITY_PRIORITIZE_BANDWIDTH);
+        return 0 != (caps.getCapabilitiesInternal() & anyPrioritizeCapability);
+    }
+
+    /**
      * Get the best network among a list of candidates according to policy.
      * @param candidates the candidates
      * @param currentSatisfier the current satisfier, or null if none
@@ -324,6 +339,12 @@
         // change from the previous result. If there were, it's guaranteed candidates.size() > 0
         // because accepted.size() > 0 above.
 
+        // If any network is not a slice with prioritized bandwidth or latency, don't choose one
+        // that is.
+        partitionInto(candidates, nai -> !hasPrioritizedCapability(nai), accepted, rejected);
+        if (accepted.size() == 1) return accepted.get(0);
+        if (accepted.size() > 0 && rejected.size() > 0) candidates = new ArrayList<>(accepted);
+
         // If some of the networks have a better transport than others, keep only the ones with
         // the best transports.
         for (final int transport : PREFERRED_TRANSPORTS_ORDER) {
diff --git a/service/src/com/android/server/connectivity/RoutingCoordinatorService.java b/service/src/com/android/server/connectivity/RoutingCoordinatorService.java
new file mode 100644
index 0000000..3350d2d
--- /dev/null
+++ b/service/src/com/android/server/connectivity/RoutingCoordinatorService.java
@@ -0,0 +1,227 @@
+/*
+ * 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.connectivity;
+
+import static com.android.net.module.util.NetdUtils.toRouteInfoParcel;
+
+import android.annotation.NonNull;
+import android.net.INetd;
+import android.net.IRoutingCoordinator;
+import android.net.RouteInfo;
+import android.os.RemoteException;
+import android.os.ServiceSpecificException;
+import android.util.ArraySet;
+import android.util.Log;
+
+import com.android.internal.annotations.GuardedBy;
+
+import java.util.Objects;
+
+/**
+ * Class to coordinate routing across multiple clients.
+ *
+ * At present this is just a wrapper for netd methods, but it will be used to host some more
+ * coordination logic in the near future. It can be used to pull up some of the routing logic
+ * from netd into Java land.
+ *
+ * Note that usage of this class is not thread-safe. Clients are responsible for their own
+ * synchronization.
+ */
+public class RoutingCoordinatorService extends IRoutingCoordinator.Stub {
+    private static final String TAG = RoutingCoordinatorService.class.getSimpleName();
+    private final INetd mNetd;
+
+    public RoutingCoordinatorService(@NonNull INetd netd) {
+        mNetd = netd;
+    }
+
+    /**
+     * Add a route for specific network
+     *
+     * @param netId the network to add the route to
+     * @param route the route to add
+     * @throws ServiceSpecificException in case of failure, with an error code indicating the
+     *         cause of the failure.
+     */
+    @Override
+    public void addRoute(final int netId, final RouteInfo route)
+            throws ServiceSpecificException, RemoteException {
+        mNetd.networkAddRouteParcel(netId, toRouteInfoParcel(route));
+    }
+
+    /**
+     * Remove a route for specific network
+     *
+     * @param netId the network to remove the route from
+     * @param route the route to remove
+     * @throws ServiceSpecificException in case of failure, with an error code indicating the
+     *         cause of the failure.
+     */
+    @Override
+    public void removeRoute(final int netId, final RouteInfo route)
+            throws ServiceSpecificException, RemoteException {
+        mNetd.networkRemoveRouteParcel(netId, toRouteInfoParcel(route));
+    }
+
+    /**
+     * Update a route for specific network
+     *
+     * @param netId the network to update the route for
+     * @param route parcelable with route information
+     * @throws ServiceSpecificException in case of failure, with an error code indicating the
+     *         cause of the failure.
+     */
+    @Override
+    public void updateRoute(final int netId, final RouteInfo route)
+            throws ServiceSpecificException, RemoteException {
+        mNetd.networkUpdateRouteParcel(netId, toRouteInfoParcel(route));
+    }
+
+    /**
+     * Adds an interface to a network. The interface must not be assigned to any network, including
+     * the specified network.
+     *
+     * @param netId the network to add the interface to.
+     * @param iface the name of the interface to add.
+     *
+     * @throws ServiceSpecificException in case of failure, with an error code corresponding to the
+     *         unix errno.
+     */
+    @Override
+    public void addInterfaceToNetwork(final int netId, final String iface)
+            throws ServiceSpecificException, RemoteException {
+        mNetd.networkAddInterface(netId, iface);
+    }
+
+    /**
+     * Removes an interface from a network. The interface must be assigned to the specified network.
+     *
+     * @param netId the network to remove the interface from.
+     * @param iface the name of the interface to remove.
+     *
+     * @throws ServiceSpecificException in case of failure, with an error code corresponding to the
+     *         unix errno.
+     */
+    @Override
+    public void removeInterfaceFromNetwork(final int netId, final String iface)
+            throws ServiceSpecificException, RemoteException {
+        mNetd.networkRemoveInterface(netId, iface);
+    }
+
+    private final Object mIfacesLock = new Object();
+    private static final class ForwardingPair {
+        @NonNull public final String fromIface;
+        @NonNull public final String toIface;
+        ForwardingPair(@NonNull final String fromIface, @NonNull final String toIface) {
+            this.fromIface = fromIface;
+            this.toIface = toIface;
+        }
+
+        @Override
+        public boolean equals(final Object o) {
+            if (this == o) return true;
+            if (!(o instanceof ForwardingPair)) return false;
+
+            final ForwardingPair that = (ForwardingPair) o;
+
+            return fromIface.equals(that.fromIface) && toIface.equals(that.toIface);
+        }
+
+        @Override
+        public int hashCode() {
+            int result = fromIface.hashCode();
+            result = 2 * result + toIface.hashCode();
+            return result;
+        }
+    }
+
+    @GuardedBy("mIfacesLock")
+    private final ArraySet<ForwardingPair> mForwardedInterfaces = new ArraySet<>();
+
+    /**
+     * Add forwarding ip rule
+     *
+     * @param fromIface interface name to add forwarding ip rule
+     * @param toIface interface name to add forwarding ip rule
+     * @throws ServiceSpecificException in case of failure, with an error code indicating the
+     *         cause of the failure.
+     */
+    public void addInterfaceForward(final String fromIface, final String toIface)
+            throws ServiceSpecificException, RemoteException {
+        Objects.requireNonNull(fromIface);
+        Objects.requireNonNull(toIface);
+        Log.i(TAG, "Adding interface forward " + fromIface + " → " + toIface);
+        synchronized (mIfacesLock) {
+            if (mForwardedInterfaces.size() == 0) {
+                mNetd.ipfwdEnableForwarding("RoutingCoordinator");
+            }
+            final ForwardingPair fwp = new ForwardingPair(fromIface, toIface);
+            if (mForwardedInterfaces.contains(fwp)) {
+                throw new IllegalStateException("Forward already exists between ifaces "
+                        + fromIface + " → " + toIface);
+            }
+            mForwardedInterfaces.add(fwp);
+            // Enables NAT for v4 and filters packets from unknown interfaces
+            mNetd.tetherAddForward(fromIface, toIface);
+            mNetd.ipfwdAddInterfaceForward(fromIface, toIface);
+        }
+    }
+
+    /**
+     * Remove forwarding ip rule
+     *
+     * @param fromIface interface name to remove forwarding ip rule
+     * @param toIface interface name to remove forwarding ip rule
+     * @throws ServiceSpecificException in case of failure, with an error code indicating the
+     *         cause of the failure.
+     */
+    public void removeInterfaceForward(final String fromIface, final String toIface)
+            throws ServiceSpecificException, RemoteException {
+        Objects.requireNonNull(fromIface);
+        Objects.requireNonNull(toIface);
+        Log.i(TAG, "Removing interface forward " + fromIface + " → " + toIface);
+        synchronized (mIfacesLock) {
+            final ForwardingPair fwp = new ForwardingPair(fromIface, toIface);
+            if (!mForwardedInterfaces.contains(fwp)) {
+                // This can happen when an upstream was unregisteredAfterReplacement. The forward
+                // is removed immediately when the upstream is destroyed, but later when the
+                // network actually disconnects CS does not know that and it asks for removal
+                // again.
+                // This can also happen if the network was destroyed before being set as an
+                // upstream, because then CS does not set up the forward rules seeing how the
+                // interface was removed anyway.
+                // Either way, this is benign.
+                Log.i(TAG, "No forward set up between interfaces " + fromIface + " → " + toIface);
+                return;
+            }
+            mForwardedInterfaces.remove(fwp);
+            try {
+                mNetd.ipfwdRemoveInterfaceForward(fromIface, toIface);
+            } catch (RemoteException | ServiceSpecificException e) {
+                Log.e(TAG, "Exception in ipfwdRemoveInterfaceForward", e);
+            }
+            try {
+                mNetd.tetherRemoveForward(fromIface, toIface);
+            } catch (RemoteException | ServiceSpecificException e) {
+                Log.e(TAG, "Exception in tetherRemoveForward", e);
+            }
+            if (mForwardedInterfaces.size() == 0) {
+                mNetd.ipfwdDisableForwarding("RoutingCoordinator");
+            }
+        }
+    }
+}
diff --git a/staticlibs/Android.bp b/staticlibs/Android.bp
index ee79ef2..0bcb757 100644
--- a/staticlibs/Android.bp
+++ b/staticlibs/Android.bp
@@ -48,7 +48,7 @@
       // "src_devicecommon/**/*.kt",
   ],
   sdk_version: "module_current",
-  min_sdk_version: "29",
+  min_sdk_version: "30",
   target_sdk_version: "30",
   apex_available: [
       "//apex_available:anyapex",
@@ -74,7 +74,10 @@
       "framework-configinfrastructure",
       "framework-connectivity.stubs.module_lib",
   ],
-  lint: { strict_updatability_linting: true },
+  lint: {
+      strict_updatability_linting: true,
+      error_checks: ["NewApi"],
+  },
 }
 
 java_defaults {
@@ -128,7 +131,7 @@
         "framework/com/android/net/module/util/HexDump.java",
     ],
     sdk_version: "module_current",
-    min_sdk_version: "29",
+    min_sdk_version: "30",
     visibility: [
         "//packages/modules/Connectivity:__subpackages__",
         "//packages/modules/NetworkStack:__subpackages__",
@@ -141,7 +144,10 @@
         "com.android.tethering",
         "//apex_available:platform",
     ],
-    lint: { strict_updatability_linting: true },
+    lint: {
+        strict_updatability_linting: true,
+        error_checks: ["NewApi"],
+    },
 }
 
 java_library {
@@ -153,7 +159,7 @@
         "device/com/android/net/module/util/structs/*.java",
     ],
     sdk_version: "module_current",
-    min_sdk_version: "29",
+    min_sdk_version: "30",
     visibility: [
         "//packages/modules/Connectivity:__subpackages__",
         "//packages/modules/NetworkStack:__subpackages__",
@@ -169,34 +175,45 @@
         "com.android.tethering",
         "//apex_available:platform",
     ],
-    lint: { strict_updatability_linting: true },
+    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.
 java_library {
     name: "net-utils-device-common-netlink",
     srcs: [
         "device/com/android/net/module/util/netlink/*.java",
     ],
     sdk_version: "module_current",
-    min_sdk_version: "29",
+    min_sdk_version: "30",
     visibility: [
         "//packages/modules/Connectivity:__subpackages__",
         "//packages/modules/NetworkStack:__subpackages__",
     ],
-    static_libs: [
-        "net-utils-device-common-struct",
-    ],
     libs: [
         "androidx.annotation_annotation",
         "framework-connectivity.stubs.module_lib",
+        // 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",
     ],
     apex_available: [
         "com.android.tethering",
         "//apex_available:platform",
     ],
-    lint: { strict_updatability_linting: true },
+    lint: {
+        strict_updatability_linting: true,
+        error_checks: ["NewApi"],
+    },
 }
 
+// The net-utils-device-common-ip library requires the callers to contain
+// net-utils-device-common-struct.
 java_library {
     // TODO : this target should probably be folded into net-utils-device-common
     name: "net-utils-device-common-ip",
@@ -204,7 +221,7 @@
         "device/com/android/net/module/util/ip/*.java",
     ],
     sdk_version: "module_current",
-    min_sdk_version: "29",
+    min_sdk_version: "30",
     visibility: [
         "//packages/modules/Connectivity:__subpackages__",
         "//packages/modules/NetworkStack:__subpackages__",
@@ -223,7 +240,10 @@
         "com.android.tethering",
         "//apex_available:platform",
     ],
-    lint: { strict_updatability_linting: true },
+    lint: {
+        strict_updatability_linting: true,
+        error_checks: ["NewApi"],
+    },
 }
 
 java_library {
@@ -232,7 +252,7 @@
         ":net-utils-framework-common-srcs",
     ],
     sdk_version: "module_current",
-    min_sdk_version: "29",
+    min_sdk_version: "30",
     libs: [
         "androidx.annotation_annotation",
         "framework-annotations-lib",
@@ -258,7 +278,10 @@
         "//packages/modules/Wifi/framework/tests:__subpackages__",
         "//packages/apps/Settings",
     ],
-    lint: { strict_updatability_linting: true },
+    lint: {
+        strict_updatability_linting: true,
+        error_checks: ["NewApi"],
+    },
     errorprone: {
         enabled: true,
         // Error-prone checking only warns of problems when building. To make the build fail with
@@ -301,7 +324,10 @@
         "//packages/modules/Bluetooth/android/app",
         "//packages/modules/Wifi/service:__subpackages__",
     ],
-    lint: { strict_updatability_linting: true },
+    lint: {
+        strict_updatability_linting: true,
+        error_checks: ["NewApi"],
+    },
 }
 
 java_library {
@@ -310,7 +336,7 @@
         "device/com/android/net/module/util/async/*.java",
     ],
     sdk_version: "module_current",
-    min_sdk_version: "29",
+    min_sdk_version: "30",
     visibility: [
         "//packages/modules/Connectivity:__subpackages__",
     ],
@@ -323,7 +349,10 @@
         "com.android.tethering",
         "//apex_available:platform",
     ],
-    lint: { strict_updatability_linting: true },
+    lint: {
+        strict_updatability_linting: true,
+        error_checks: ["NewApi"],
+    },
 }
 
 java_library {
@@ -332,7 +361,7 @@
         "device/com/android/net/module/util/wear/*.java",
     ],
     sdk_version: "module_current",
-    min_sdk_version: "29",
+    min_sdk_version: "30",
     visibility: [
         "//packages/modules/Connectivity:__subpackages__",
     ],
@@ -346,7 +375,10 @@
         "com.android.tethering",
         "//apex_available:platform",
     ],
-    lint: { strict_updatability_linting: true },
+    lint: {
+        strict_updatability_linting: true,
+        error_checks: ["NewApi"],
+    },
 }
 
 // Limited set of utilities for use by service-connectivity-mdns-standalone-build-test, to make sure
diff --git a/staticlibs/client-libs/Android.bp b/staticlibs/client-libs/Android.bp
index c560045..c938dd6 100644
--- a/staticlibs/client-libs/Android.bp
+++ b/staticlibs/client-libs/Android.bp
@@ -6,7 +6,7 @@
     name: "netd-client",
     srcs: ["netd/**/*"],
     sdk_version: "system_current",
-    min_sdk_version: "29",
+    min_sdk_version: "30",
     apex_available: [
         "//apex_available:platform",
         "com.android.tethering",
diff --git a/staticlibs/client-libs/netd/com/android/net/module/util/NetdUtils.java b/staticlibs/client-libs/netd/com/android/net/module/util/NetdUtils.java
index 98fda56..1d8b4eb 100644
--- a/staticlibs/client-libs/netd/com/android/net/module/util/NetdUtils.java
+++ b/staticlibs/client-libs/netd/com/android/net/module/util/NetdUtils.java
@@ -28,6 +28,7 @@
 import android.net.InterfaceConfigurationParcel;
 import android.net.IpPrefix;
 import android.net.RouteInfo;
+import android.net.RouteInfoParcel;
 import android.net.TetherConfigParcel;
 import android.os.RemoteException;
 import android.os.ServiceSpecificException;
@@ -159,9 +160,11 @@
             throws RemoteException, ServiceSpecificException {
         netd.tetherInterfaceAdd(iface);
         networkAddInterface(netd, iface, maxAttempts, pollingIntervalMs);
-        List<RouteInfo> routes = new ArrayList<>();
-        routes.add(new RouteInfo(dest, null, iface, RTN_UNICAST));
-        addRoutesToLocalNetwork(netd, iface, routes);
+        // Activate a route to dest and IPv6 link local.
+        modifyRoute(netd, ModifyOperation.ADD, INetd.LOCAL_NET_ID,
+                new RouteInfo(dest, null, iface, RTN_UNICAST));
+        modifyRoute(netd, ModifyOperation.ADD, INetd.LOCAL_NET_ID,
+                new RouteInfo(new IpPrefix("fe80::/64"), null, iface, RTN_UNICAST));
     }
 
     /**
@@ -255,7 +258,7 @@
     }
 
     /** Add or remove |route|. */
-    public static void modifyRoute(final INetd netd, final ModifyOperation op, final int netId,
+    private static void modifyRoute(final INetd netd, final ModifyOperation op, final int netId,
             final RouteInfo route) {
         final String ifName = route.getInterface();
         final String dst = route.getDestination().toString();
@@ -276,4 +279,38 @@
             throw new IllegalStateException(e);
         }
     }
+
+    /**
+     * Convert a RouteInfo into a RouteInfoParcel.
+     */
+    public static RouteInfoParcel toRouteInfoParcel(RouteInfo route) {
+        final String nextHop;
+
+        switch (route.getType()) {
+            case RouteInfo.RTN_UNICAST:
+                if (route.hasGateway()) {
+                    nextHop = route.getGateway().getHostAddress();
+                } else {
+                    nextHop = INetd.NEXTHOP_NONE;
+                }
+                break;
+            case RouteInfo.RTN_UNREACHABLE:
+                nextHop = INetd.NEXTHOP_UNREACHABLE;
+                break;
+            case RouteInfo.RTN_THROW:
+                nextHop = INetd.NEXTHOP_THROW;
+                break;
+            default:
+                nextHop = INetd.NEXTHOP_NONE;
+                break;
+        }
+
+        final RouteInfoParcel rip = new RouteInfoParcel();
+        rip.ifName = route.getInterface();
+        rip.destination = route.getDestination().toString();
+        rip.nextHop = nextHop;
+        rip.mtu = route.getMtu();
+
+        return rip;
+    }
 }
diff --git a/staticlibs/client-libs/tests/unit/Android.bp b/staticlibs/client-libs/tests/unit/Android.bp
index 220a6c1..03e3e70 100644
--- a/staticlibs/client-libs/tests/unit/Android.bp
+++ b/staticlibs/client-libs/tests/unit/Android.bp
@@ -8,7 +8,7 @@
         "src/**/*.java",
         "src/**/*.kt",
     ],
-    min_sdk_version: "29",
+    min_sdk_version: "30",
     static_libs: [
         "androidx.test.rules",
         "mockito-target-extended-minus-junit4",
diff --git a/staticlibs/device/com/android/net/module/util/BpfMap.java b/staticlibs/device/com/android/net/module/util/BpfMap.java
index d45cace..595ac74 100644
--- a/staticlibs/device/com/android/net/module/util/BpfMap.java
+++ b/staticlibs/device/com/android/net/module/util/BpfMap.java
@@ -187,12 +187,6 @@
         return nativeDeleteMapEntry(mMapFd.getFd(), key.writeToBytes());
     }
 
-    /** Returns {@code true} if this map contains no elements. */
-    @Override
-    public boolean isEmpty() throws ErrnoException {
-        return getFirstKey() == null;
-    }
-
     private K getNextKeyInternal(@Nullable K key) throws ErrnoException {
         byte[] rawKey = new byte[mKeySize];
 
@@ -245,49 +239,6 @@
         return Struct.parse(mValueClass, buffer);
     }
 
-    /**
-     * Iterate through the map and handle each key -> value retrieved base on the given BiConsumer.
-     * The given BiConsumer may to delete the passed-in entry, but is not allowed to perform any
-     * other structural modifications to the map, such as adding entries or deleting other entries.
-     * Otherwise, iteration will result in undefined behaviour.
-     */
-    @Override
-    public void forEach(ThrowingBiConsumer<K, V> action) throws ErrnoException {
-        @Nullable K nextKey = getFirstKey();
-
-        while (nextKey != null) {
-            @NonNull final K curKey = nextKey;
-            @NonNull final V value = getValue(curKey);
-
-            nextKey = getNextKey(curKey);
-            action.accept(curKey, value);
-        }
-    }
-
-    /* Empty implementation to implement AutoCloseable, so we can use BpfMaps
-     * with try with resources, but due to persistent FD cache, there is no actual
-     * need to close anything.  File descriptors will actually be closed when we
-     * unlock the BpfMap class and destroy the ParcelFileDescriptor objects.
-     */
-    @Override
-    public void close() throws IOException {
-    }
-
-    /**
-     * Clears the map. The map may already be empty.
-     *
-     * @throws ErrnoException if the map is already closed, if an error occurred during iteration,
-     *                        or if a non-ENOENT error occurred when deleting a key.
-     */
-    @Override
-    public void clear() throws ErrnoException {
-        K key = getFirstKey();
-        while (key != null) {
-            deleteEntry(key);  // ignores ENOENT.
-            key = getFirstKey();
-        }
-    }
-
     private static native int nativeBpfFdGet(String path, int mode, int keySize, int valueSize)
             throws ErrnoException, NullPointerException;
 
diff --git a/staticlibs/device/com/android/net/module/util/BpfUtils.java b/staticlibs/device/com/android/net/module/util/BpfUtils.java
index 94af11b..10a8f60 100644
--- a/staticlibs/device/com/android/net/module/util/BpfUtils.java
+++ b/staticlibs/device/com/android/net/module/util/BpfUtils.java
@@ -32,38 +32,27 @@
     // Defined in include/uapi/linux/bpf.h. Only adding the CGROUPS currently being used for now.
     public static final int BPF_CGROUP_INET_INGRESS = 0;
     public static final int BPF_CGROUP_INET_EGRESS = 1;
+    public static final int BPF_CGROUP_INET_SOCK_CREATE = 2;
     public static final int BPF_CGROUP_INET4_BIND = 8;
     public static final int BPF_CGROUP_INET6_BIND = 9;
 
+    // Note: This is only guaranteed to be accurate on U+ devices. It is likely to be accurate
+    // on T+ devices as well, but this is not guaranteed.
+    public static final String CGROUP_PATH = "/sys/fs/cgroup/";
 
     /**
-     * Attach BPF program to CGROUP
+     * Get BPF program Id from CGROUP.
+     *
+     * Note: This requires a 4.19 kernel which is only guaranteed on V+.
+     *
+     * @param attachType Bpf attach type. See bpf_attach_type in include/uapi/linux/bpf.h.
+     * @return Positive integer for a Program Id. 0 if no program is attached.
+     * @throws IOException if failed to open the cgroup directory or query bpf program.
      */
-    public static void attachProgram(int type, @NonNull String programPath,
-            @NonNull String cgroupPath, int flags) throws IOException {
-        native_attachProgramToCgroup(type, programPath, cgroupPath, flags);
+    public static int getProgramId(int attachType) throws IOException {
+        return native_getProgramIdFromCgroup(attachType, CGROUP_PATH);
     }
 
-    /**
-     * Detach BPF program from CGROUP
-     */
-    public static void detachProgram(int type, @NonNull String cgroupPath)
-            throws IOException {
-        native_detachProgramFromCgroup(type, cgroupPath);
-    }
-
-    /**
-     * Detach single BPF program from CGROUP
-     */
-    public static void detachSingleProgram(int type, @NonNull String programPath,
-            @NonNull String cgroupPath) throws IOException {
-        native_detachSingleProgramFromCgroup(type, programPath, cgroupPath);
-    }
-
-    private static native boolean native_attachProgramToCgroup(int type, String programPath,
-            String cgroupPath, int flags) throws IOException;
-    private static native boolean native_detachProgramFromCgroup(int type, String cgroupPath)
+    private static native int native_getProgramIdFromCgroup(int type, String cgroupPath)
             throws IOException;
-    private static native boolean native_detachSingleProgramFromCgroup(int type,
-            String programPath, String cgroupPath) throws IOException;
 }
diff --git a/staticlibs/device/com/android/net/module/util/DeviceConfigUtils.java b/staticlibs/device/com/android/net/module/util/DeviceConfigUtils.java
index e646f37..42f26f4 100644
--- a/staticlibs/device/com/android/net/module/util/DeviceConfigUtils.java
+++ b/staticlibs/device/com/android/net/module/util/DeviceConfigUtils.java
@@ -40,6 +40,7 @@
 
 import java.util.ArrayList;
 import java.util.List;
+import java.util.function.Supplier;
 
 /**
  * Utilities for modules to query {@link DeviceConfig} and flags.
@@ -63,6 +64,9 @@
     @VisibleForTesting
     public static final long DEFAULT_PACKAGE_VERSION = 1000;
 
+    private static final String CORE_NETWORKING_TRUNK_STABLE_NAMESPACE = "android_core_networking";
+    private static final String CORE_NETWORKING_TRUNK_STABLE_FLAG_PACKAGE = "com.android.net.flags";
+
     @VisibleForTesting
     public static void resetPackageVersionCacheForTest() {
         sPackageVersion = -1;
@@ -70,6 +74,9 @@
         sNetworkStackModuleVersion = -1;
     }
 
+    private static final int FORCE_ENABLE_FEATURE_FLAG_VALUE = 1;
+    private static final int FORCE_DISABLE_FEATURE_FLAG_VALUE = -1;
+
     private static volatile long sPackageVersion = -1;
     private static long getPackageVersion(@NonNull final Context context) {
         // sPackageVersion may be set by another thread just after this check, but querying the
@@ -161,34 +168,19 @@
      *
      * 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 #isNetworkStackFeatureNotChickenedOut(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 isNetworkStackFeatureEnabled(@NonNull Context context,
             @NonNull String name) {
-        return isNetworkStackFeatureEnabled(context, name, false /* defaultEnabled */);
-    }
-
-    /**
-     * 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.
-     * @param context The global context information about an app environment.
-     * @param name The name of the property to look up.
-     * @param defaultEnabled The value to return if the property does not exist or its value is
-     *                       null.
-     * @return true if this feature is enabled, or false if disabled.
-     */
-    public static boolean isNetworkStackFeatureEnabled(@NonNull Context context,
-            @NonNull String name, boolean defaultEnabled) {
-        final long packageVersion = getPackageVersion(context);
-        return isFeatureEnabled(context, packageVersion, NAMESPACE_CONNECTIVITY, name,
-                defaultEnabled);
+        return isFeatureEnabled(NAMESPACE_CONNECTIVITY, name, false /* defaultEnabled */,
+                () -> getPackageVersion(context));
     }
 
     /**
@@ -202,7 +194,7 @@
      *
      * 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 #isTetheringFeatureNotChickenedOut(String)} should be used.
+     * {@link #isTetheringFeatureNotChickenedOut(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.
@@ -210,17 +202,24 @@
      */
     public static boolean isTetheringFeatureEnabled(@NonNull Context context,
             @NonNull String name) {
-        final long packageVersion = getTetheringModuleVersion(context);
-        return isFeatureEnabled(context, packageVersion, NAMESPACE_TETHERING, name,
-                false /* defaultEnabled */);
+        return isFeatureEnabled(NAMESPACE_TETHERING, name, false /* defaultEnabled */,
+                () -> getTetheringModuleVersion(context));
     }
 
-    private static boolean isFeatureEnabled(@NonNull Context context, long packageVersion,
-            @NonNull String namespace, String name, boolean defaultEnabled) {
-        final int propertyVersion = getDeviceConfigPropertyInt(namespace, name,
-                0 /* default value */);
-        return (propertyVersion == 0 && defaultEnabled)
-                || (propertyVersion != 0 && packageVersion >= (long) propertyVersion);
+    private static boolean isFeatureEnabled(@NonNull String namespace,
+            String name, boolean defaultEnabled, Supplier<Long> packageVersionSupplier) {
+        final int flagValue = getDeviceConfigPropertyInt(namespace, name, 0 /* default value */);
+        switch (flagValue) {
+            case 0:
+                return defaultEnabled;
+            case FORCE_DISABLE_FEATURE_FLAG_VALUE:
+                return false;
+            case FORCE_ENABLE_FEATURE_FLAG_VALUE:
+                return true;
+            default:
+                final long packageVersion = packageVersionSupplier.get();
+                return packageVersion >= (long) flagValue;
+        }
     }
 
     // Guess the tethering module name based on the package prefix of the connectivity resources
@@ -331,42 +330,38 @@
     }
 
     /**
-     * Check whether one specific experimental feature in specific namespace from
-     * {@link DeviceConfig} is not disabled. Feature can be disabled by setting a non-zero
-     * value in the property. If the feature is enabled by default and disabled by flag push
-     * (kill switch), this method should be used. If the feature is disabled by default and
-     * enabled by flag push, {@link #isFeatureEnabled} should be used.
-     *
-     * @param namespace The namespace containing the property to look up.
-     * @param name The name of the property to look up.
-     * @return true if this feature is enabled, or false if disabled.
-     */
-    private static boolean isFeatureNotChickenedOut(String namespace, String name) {
-        final int propertyVersion = getDeviceConfigPropertyInt(namespace, name,
-                0 /* default value */);
-        return propertyVersion == 0;
-    }
-
-    /**
      * Check whether one specific experimental feature in Tethering module from {@link DeviceConfig}
      * is not disabled.
+     * If the feature is enabled by default and disabled by flag push (kill switch), this method
+     * should be used.
+     * If the feature is disabled by default and enabled by flag push,
+     * {@link #isTetheringFeatureEnabled(Context, String)} should be used.
      *
+     * @param context The global context information about an app environment.
      * @param name The name of the property in tethering module to look up.
      * @return true if this feature is enabled, or false if disabled.
      */
-    public static boolean isTetheringFeatureNotChickenedOut(String name) {
-        return isFeatureNotChickenedOut(NAMESPACE_TETHERING, name);
+    public static boolean isTetheringFeatureNotChickenedOut(@NonNull Context context, String name) {
+        return isFeatureEnabled(NAMESPACE_TETHERING, name, true /* defaultEnabled */,
+                () -> getTetheringModuleVersion(context));
     }
 
     /**
      * Check whether one specific experimental feature in NetworkStack module from
      * {@link DeviceConfig} is not disabled.
+     * If the feature is enabled by default and disabled by flag push (kill switch), this method
+     * should be used.
+     * If the feature is disabled by default and enabled by flag push,
+     * {@link #isNetworkStackFeatureEnabled(Context, String)} should be used.
      *
+     * @param context The global context information about an app environment.
      * @param name The name of the property in NetworkStack module to look up.
      * @return true if this feature is enabled, or false if disabled.
      */
-    public static boolean isNetworkStackFeatureNotChickenedOut(String name) {
-        return isFeatureNotChickenedOut(NAMESPACE_CONNECTIVITY, name);
+    public static boolean isNetworkStackFeatureNotChickenedOut(
+            @NonNull Context context, String name) {
+        return isFeatureEnabled(NAMESPACE_CONNECTIVITY, name, true /* defaultEnabled */,
+                () -> getPackageVersion(context));
     }
 
     /**
@@ -414,4 +409,31 @@
 
         return pkgs.get(0).activityInfo.applicationInfo.packageName;
     }
+
+    /**
+     * Check whether one specific trunk stable flag in android_core_networking namespace is enabled.
+     * This method reads trunk stable feature flag value from DeviceConfig directly since
+     * java_aconfig_library soong module is not available in the mainline branch.
+     * After the mainline branch support the aconfig soong module, this function must be removed and
+     * java_aconfig_library must be used instead to check if the feature is enabled.
+     *
+     * @param flagName The name of the trunk stable flag
+     * @return true if this feature is enabled, or false if disabled.
+     */
+    public static boolean isTrunkStableFeatureEnabled(final String flagName) {
+        return isTrunkStableFeatureEnabled(
+                CORE_NETWORKING_TRUNK_STABLE_NAMESPACE,
+                CORE_NETWORKING_TRUNK_STABLE_FLAG_PACKAGE,
+                flagName
+        );
+    }
+
+    private static boolean isTrunkStableFeatureEnabled(final String namespace,
+            final String packageName, final String flagName) {
+        return DeviceConfig.getBoolean(
+                namespace,
+                packageName + "." + flagName,
+                false /* defaultValue */
+        );
+    }
 }
diff --git a/staticlibs/device/com/android/net/module/util/FeatureVersions.java b/staticlibs/device/com/android/net/module/util/FeatureVersions.java
index 149756c..d5f8124 100644
--- a/staticlibs/device/com/android/net/module/util/FeatureVersions.java
+++ b/staticlibs/device/com/android/net/module/util/FeatureVersions.java
@@ -42,4 +42,10 @@
     // M-2023-Sept on July 3rd, 2023.
     public static final long FEATURE_CLAT_ADDRESS_TRANSLATE =
             NETWORK_STACK_MODULE_ID + 34_09_00_000L;
+
+    // IS_UID_NETWORKING_BLOCKED is a feature in ConnectivityManager,
+    // which provides an API to access BPF maps to check whether the networking is blocked
+    // by BPF for the given uid and conditions, introduced in version M-2024-Feb on Nov 6, 2023.
+    public static final long FEATURE_IS_UID_NETWORKING_BLOCKED =
+            CONNECTIVITY_MODULE_ID + 34_14_00_000L;
 }
diff --git a/staticlibs/device/com/android/net/module/util/IBpfMap.java b/staticlibs/device/com/android/net/module/util/IBpfMap.java
index 83ff875..ca56830 100644
--- a/staticlibs/device/com/android/net/module/util/IBpfMap.java
+++ b/staticlibs/device/com/android/net/module/util/IBpfMap.java
@@ -18,6 +18,7 @@
 import android.system.ErrnoException;
 
 import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 
 import java.io.IOException;
 import java.util.NoSuchElementException;
@@ -49,15 +50,17 @@
     /** Remove existing key from eBpf map. Return true if something was deleted. */
     boolean deleteEntry(K key) throws ErrnoException;
 
-    /** Returns {@code true} if this map contains no elements. */
-    boolean isEmpty() throws ErrnoException;
-
     /** Get the key after the passed-in key. */
     K getNextKey(@NonNull K key) throws ErrnoException;
 
     /** Get the first key of the eBpf map. */
     K getFirstKey() throws ErrnoException;
 
+    /** Returns {@code true} if this map contains no elements. */
+    default boolean isEmpty() throws ErrnoException {
+        return getFirstKey() == null;
+    }
+
     /** Check whether a key exists in the map. */
     boolean containsKey(@NonNull K key) throws ErrnoException;
 
@@ -70,13 +73,38 @@
 
     /**
      * Iterate through the map and handle each key -> value retrieved base on the given BiConsumer.
+     * The given BiConsumer may to delete the passed-in entry, but is not allowed to perform any
+     * other structural modifications to the map, such as adding entries or deleting other entries.
+     * Otherwise, iteration will result in undefined behaviour.
      */
-    void forEach(ThrowingBiConsumer<K, V> action) throws ErrnoException;
+    default public void forEach(ThrowingBiConsumer<K, V> action) throws ErrnoException {
+        @Nullable K nextKey = getFirstKey();
 
-    /** Clears the map. */
-    void clear() throws ErrnoException;
+        while (nextKey != null) {
+            @NonNull final K curKey = nextKey;
+            @NonNull final V value = getValue(curKey);
+
+            nextKey = getNextKey(curKey);
+            action.accept(curKey, value);
+        }
+    }
+
+    /**
+     * Clears the map. The map may already be empty.
+     *
+     * @throws ErrnoException if the map is already closed, if an error occurred during iteration,
+     *                        or if a non-ENOENT error occurred when deleting a key.
+     */
+    default public void clear() throws ErrnoException {
+        K key = getFirstKey();
+        while (key != null) {
+            deleteEntry(key);  // ignores ENOENT.
+            key = getFirstKey();
+        }
+    }
 
     /** Close for AutoCloseable. */
     @Override
-    void close() throws IOException;
+    default void close() throws IOException {
+    };
 }
diff --git a/staticlibs/device/com/android/net/module/util/SocketUtils.java b/staticlibs/device/com/android/net/module/util/SocketUtils.java
index 9878ea5..5e6a6c6 100644
--- a/staticlibs/device/com/android/net/module/util/SocketUtils.java
+++ b/staticlibs/device/com/android/net/module/util/SocketUtils.java
@@ -19,6 +19,8 @@
 import static android.net.util.SocketUtils.closeSocket;
 
 import android.annotation.NonNull;
+import android.annotation.RequiresApi;
+import android.os.Build;
 import android.system.NetlinkSocketAddress;
 
 import java.io.FileDescriptor;
@@ -39,7 +41,7 @@
     /**
      * Make a socket address to communicate with netlink.
      */
-    @NonNull
+    @NonNull @RequiresApi(Build.VERSION_CODES.S)
     public static SocketAddress makeNetlinkSocketAddress(int portId, int groupsMask) {
         return new NetlinkSocketAddress(portId, groupsMask);
     }
diff --git a/staticlibs/device/com/android/net/module/util/Struct.java b/staticlibs/device/com/android/net/module/util/Struct.java
index b638a46..ff7a711 100644
--- a/staticlibs/device/com/android/net/module/util/Struct.java
+++ b/staticlibs/device/com/android/net/module/util/Struct.java
@@ -146,6 +146,14 @@
         int arraysize() default 0;
     }
 
+    /**
+     * Indicates that this field contains a computed value and is ignored for the purposes of Struct
+     * parsing.
+     */
+    @Retention(RetentionPolicy.RUNTIME)
+    @Target(ElementType.FIELD)
+    public @interface Computed {}
+
     private static class FieldInfo {
         @NonNull
         public final Field annotation;
@@ -414,7 +422,14 @@
                 final byte[] address = new byte[isIpv6 ? 16 : 4];
                 buf.get(address);
                 try {
-                    value = InetAddress.getByAddress(address);
+                    if (isIpv6) {
+                        // Using Inet6Address.getByAddress since InetAddress.getByAddress converts
+                        // v4-mapped v6 address to v4 address internally and returns Inet4Address.
+                        value = Inet6Address.getByAddress(
+                                null /* host */, address, -1 /* scope_id */);
+                    } else {
+                        value = InetAddress.getByAddress(address);
+                    }
                 } catch (UnknownHostException e) {
                     throw new IllegalArgumentException("illegal length of IP address", e);
                 }
@@ -533,6 +548,7 @@
         final FieldInfo[] annotationFields = new FieldInfo[getAnnotationFieldCount(clazz)];
         for (java.lang.reflect.Field field : clazz.getDeclaredFields()) {
             if (Modifier.isStatic(field.getModifiers())) continue;
+            if (field.getAnnotation(Computed.class) != null) continue;
 
             final Field annotation = field.getAnnotation(Field.class);
             if (annotation == null) {
diff --git a/staticlibs/device/com/android/net/module/util/ip/NetlinkMonitor.java b/staticlibs/device/com/android/net/module/util/ip/NetlinkMonitor.java
index f882483..15a4633 100644
--- a/staticlibs/device/com/android/net/module/util/ip/NetlinkMonitor.java
+++ b/staticlibs/device/com/android/net/module/util/ip/NetlinkMonitor.java
@@ -109,7 +109,7 @@
                 }
             }
             Os.bind(fd, makeNetlinkSocketAddress(0, mBindGroups));
-            NetlinkUtils.connectSocketToNetlink(fd);
+            NetlinkUtils.connectToKernel(fd);
 
             if (DBG) {
                 final SocketAddress nlAddr = Os.getsockname(fd);
diff --git a/staticlibs/device/com/android/net/module/util/netlink/InetDiagMessage.java b/staticlibs/device/com/android/net/module/util/netlink/InetDiagMessage.java
index f8b4716..4f76577 100644
--- a/staticlibs/device/com/android/net/module/util/netlink/InetDiagMessage.java
+++ b/staticlibs/device/com/android/net/module/util/netlink/InetDiagMessage.java
@@ -33,7 +33,7 @@
 import static com.android.net.module.util.netlink.NetlinkUtils.DEFAULT_RECV_BUFSIZE;
 import static com.android.net.module.util.netlink.NetlinkUtils.IO_TIMEOUT_MS;
 import static com.android.net.module.util.netlink.NetlinkUtils.TCP_ALIVE_STATE_FILTER;
-import static com.android.net.module.util.netlink.NetlinkUtils.connectSocketToNetlink;
+import static com.android.net.module.util.netlink.NetlinkUtils.connectToKernel;
 import static com.android.net.module.util.netlink.StructNlMsgHdr.NLM_F_DUMP;
 import static com.android.net.module.util.netlink.StructNlMsgHdr.NLM_F_REQUEST;
 
@@ -266,7 +266,7 @@
         FileDescriptor fd = null;
         try {
             fd = NetlinkUtils.netlinkSocketForProto(NETLINK_INET_DIAG);
-            NetlinkUtils.connectSocketToNetlink(fd);
+            connectToKernel(fd);
             uid = lookupUid(protocol, local, remote, fd);
         } catch (ErrnoException | SocketException | IllegalArgumentException
                 | InterruptedIOException e) {
@@ -426,8 +426,8 @@
         try {
             dumpFd = NetlinkUtils.createNetLinkInetDiagSocket();
             destroyFd = NetlinkUtils.createNetLinkInetDiagSocket();
-            connectSocketToNetlink(dumpFd);
-            connectSocketToNetlink(destroyFd);
+            connectToKernel(dumpFd);
+            connectToKernel(destroyFd);
 
             for (int family : List.of(AF_INET, AF_INET6)) {
                 try {
diff --git a/staticlibs/device/com/android/net/module/util/netlink/NetlinkUtils.java b/staticlibs/device/com/android/net/module/util/netlink/NetlinkUtils.java
index 33bd36d..f1f30d3 100644
--- a/staticlibs/device/com/android/net/module/util/netlink/NetlinkUtils.java
+++ b/staticlibs/device/com/android/net/module/util/netlink/NetlinkUtils.java
@@ -153,7 +153,7 @@
         final FileDescriptor fd = netlinkSocketForProto(nlProto);
 
         try {
-            connectSocketToNetlink(fd);
+            connectToKernel(fd);
             sendMessage(fd, msg, 0, msg.length, IO_TIMEOUT_MS);
             receiveNetlinkAck(fd);
         } catch (InterruptedIOException e) {
@@ -244,7 +244,7 @@
      * @throws ErrnoException if the {@code fd} could not connect to kernel successfully
      * @throws SocketException if there is an error accessing a socket.
      */
-    public static void connectSocketToNetlink(FileDescriptor fd)
+    public static void connectToKernel(@NonNull FileDescriptor fd)
             throws ErrnoException, SocketException {
         Os.connect(fd, makeNetlinkSocketAddress(0, 0));
     }
diff --git a/staticlibs/device/com/android/net/module/util/structs/IaPrefixOption.java b/staticlibs/device/com/android/net/module/util/structs/IaPrefixOption.java
index 59d655c..b0f19e2 100644
--- a/staticlibs/device/com/android/net/module/util/structs/IaPrefixOption.java
+++ b/staticlibs/device/com/android/net/module/util/structs/IaPrefixOption.java
@@ -18,10 +18,17 @@
 
 import static com.android.net.module.util.NetworkStackConstants.DHCP6_OPTION_IAPREFIX;
 
+import android.net.IpPrefix;
+import android.util.Log;
+
 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;
 
@@ -52,6 +59,7 @@
  * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
  */
 public class IaPrefixOption extends Struct {
+    private static final String TAG = IaPrefixOption.class.getSimpleName();
     public static final int LENGTH = 25; // option length excluding IAprefix-options
 
     @Field(order = 0, type = Type.S16)
@@ -67,7 +75,11 @@
     @Field(order = 5, type = Type.ByteArray, arraysize = 16)
     public final byte[] prefix;
 
-    public IaPrefixOption(final short code, final short length, final long preferred,
+    @Computed
+    private final IpPrefix mIpPrefix;
+
+    // Constructor used by Struct.parse()
+    protected IaPrefixOption(final short code, final short length, final long preferred,
             final long valid, final byte prefixLen, final byte[] prefix) {
         this.code = code;
         this.length = length;
@@ -75,6 +87,57 @@
         this.valid = valid;
         this.prefixLen = prefixLen;
         this.prefix = prefix.clone();
+
+        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);
+        }
+    }
+
+    public IaPrefixOption(final short length, final long preferred, final long valid,
+            final byte prefixLen, final byte[] prefix) {
+        this((byte) DHCP6_OPTION_IAPREFIX, length, preferred, valid, prefixLen, prefix);
+    }
+
+    /**
+     * Check whether or not IA Prefix option in IA_PD option is valid per RFC8415#section-21.22.
+     *
+     * Note: an expired prefix can still be valid.
+     */
+    public boolean isValid() {
+        if (preferred < 0) {
+            Log.w(TAG, "Invalid preferred lifetime: " + this);
+            return false;
+        }
+        if (valid < 0) {
+            Log.w(TAG, "Invalid valid lifetime: " + this);
+            return false;
+        }
+        if (preferred > valid) {
+            Log.w(TAG, "Invalid lifetime. Preferred lifetime > valid lifetime: " + this);
+            return false;
+        }
+        if (prefixLen > 64) {
+            Log.w(TAG, "Invalid prefix length: " + this);
+            return false;
+        }
+        return true;
+    }
+
+    public IpPrefix getIpPrefix() {
+        return mIpPrefix;
+    }
+
+    /**
+     * Check whether or not IA Prefix option has 0 preferred and valid lifetimes.
+     */
+    public boolean withZeroLifetimes() {
+        return preferred == 0 && valid == 0;
     }
 
     /**
@@ -82,8 +145,14 @@
      */
     public static ByteBuffer build(final short length, final long preferred, final long valid,
             final byte prefixLen, final byte[] prefix) {
-        final IaPrefixOption option = new IaPrefixOption((byte) DHCP6_OPTION_IAPREFIX,
+        final IaPrefixOption option = new IaPrefixOption(
                 length /* 25 + IAPrefix options length */, preferred, valid, prefixLen, prefix);
         return ByteBuffer.wrap(option.writeToBytes(ByteOrder.BIG_ENDIAN));
     }
+
+    @Override
+    public String toString() {
+        return "IA Prefix, length " + length + ": " + mIpPrefix + ", pref " + preferred + ", valid "
+                + valid;
+    }
 }
diff --git a/staticlibs/framework/com/android/net/module/util/InetAddressUtils.java b/staticlibs/framework/com/android/net/module/util/InetAddressUtils.java
index 40fc59f..4b27a97 100644
--- a/staticlibs/framework/com/android/net/module/util/InetAddressUtils.java
+++ b/staticlibs/framework/com/android/net/module/util/InetAddressUtils.java
@@ -21,6 +21,7 @@
 import android.util.Log;
 
 
+import java.net.Inet4Address;
 import java.net.Inet6Address;
 import java.net.InetAddress;
 import java.net.UnknownHostException;
@@ -32,6 +33,7 @@
 public class InetAddressUtils {
 
     private static final String TAG = InetAddressUtils.class.getSimpleName();
+    private static final int INET4_ADDR_LENGTH = 4;
     private static final int INET6_ADDR_LENGTH = 16;
 
     /**
@@ -93,5 +95,29 @@
         }
     }
 
+    /**
+     * Create a v4-mapped v6 address from v4 address
+     *
+     * @param v4Addr Inet4Address which is converted to v4-mapped v6 address
+     * @return v4-mapped v6 address
+     */
+    public static Inet6Address v4MappedV6Address(@NonNull final Inet4Address v4Addr) {
+        final byte[] v6AddrBytes = new byte[INET6_ADDR_LENGTH];
+        v6AddrBytes[10] = (byte) 0xFF;
+        v6AddrBytes[11] = (byte) 0xFF;
+        System.arraycopy(v4Addr.getAddress(), 0 /* srcPos */, v6AddrBytes, 12 /* dstPos */,
+                INET4_ADDR_LENGTH);
+        try {
+            // Using Inet6Address.getByAddress since InetAddress.getByAddress converts v4-mapped v6
+            // address to v4 address internally and returns Inet4Address
+            return Inet6Address.getByAddress(null /* host */, v6AddrBytes, -1 /* scope_id */);
+        } catch (UnknownHostException impossible) {
+            // getByAddress throws UnknownHostException when the argument is the invalid length
+            // but INET6_ADDR_LENGTH(16) is the valid length.
+            Log.wtf(TAG, "Failed to generate v4-mapped v6 address from " + v4Addr, impossible);
+            return null;
+        }
+    }
+
     private InetAddressUtils() {}
 }
diff --git a/staticlibs/framework/com/android/net/module/util/SdkUtil.java b/staticlibs/framework/com/android/net/module/util/SdkUtil.java
new file mode 100644
index 0000000..5006ba9
--- /dev/null
+++ b/staticlibs/framework/com/android/net/module/util/SdkUtil.java
@@ -0,0 +1,49 @@
+/*
+ * 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.net.module.util;
+
+import android.annotation.Nullable;
+
+/**
+ * Utilities to deal with multiple SDKs in a single mainline module.
+ * @hide
+ */
+public class SdkUtil {
+    /**
+     * Holder class taking advantage of erasure to avoid reflection running into class not found
+     * exceptions.
+     *
+     * This is useful to store a reference to a class that might not be present at runtime when
+     * fields are examined through reflection. An example is the MessageUtils class, which tries
+     * to get all fields in a class and therefore will try to load any class for which there
+     * is a member. Another example would be arguments or return values of methods in tests,
+     * when the testing framework uses reflection to list methods and their arguments.
+     *
+     * In these cases, LateSdk<T> can be used to hide type T from reflection, since it's erased
+     * and it becomes a vanilla LateSdk in Java bytecode. The T still can't be instantiated at
+     * runtime of course, but runtime tests will avoid that.
+     *
+     * @param <T> The held type
+     * @hide
+     */
+    public static class LateSdk<T> {
+        @Nullable public final T value;
+        public LateSdk(@Nullable final T value) {
+            this.value = value;
+        }
+    }
+}
diff --git a/staticlibs/lint-baseline.xml b/staticlibs/lint-baseline.xml
deleted file mode 100644
index d413b2a..0000000
--- a/staticlibs/lint-baseline.xml
+++ /dev/null
@@ -1,70 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<issues format="6" by="lint 8.0.0-dev" type="baseline" dependencies="true" variant="all" version="8.0.0-dev">
-
-    <issue
-        id="NewApi"
-        message="Call requires API level 30 (current min is 29): `android.net.LinkProperties#getAddresses`"
-        errorLine1="        final Collection&lt;InetAddress&gt; leftAddresses = left.getAddresses();"
-        errorLine2="                                                           ~~~~~~~~~~~~">
-        <location
-            file="frameworks/libs/net/common/framework/com/android/net/module/util/LinkPropertiesUtils.java"
-            line="158"
-            column="60"/>
-    </issue>
-
-    <issue
-        id="NewApi"
-        message="Call requires API level 30 (current min is 29): `android.net.LinkProperties#getAddresses`"
-        errorLine1="        final Collection&lt;InetAddress&gt; rightAddresses = right.getAddresses();"
-        errorLine2="                                                             ~~~~~~~~~~~~">
-        <location
-            file="frameworks/libs/net/common/framework/com/android/net/module/util/LinkPropertiesUtils.java"
-            line="159"
-            column="62"/>
-    </issue>
-
-    <issue
-        id="NewApi"
-        message="Call requires API level 30 (current min is 29): `android.net.NetworkStats#addEntry`"
-        errorLine1="            stats = stats.addEntry(entry);"
-        errorLine2="                          ~~~~~~~~">
-        <location
-            file="frameworks/libs/net/common/framework/com/android/net/module/util/NetworkStatsUtils.java"
-            line="113"
-            column="27"/>
-    </issue>
-
-    <issue
-        id="NewApi"
-        message="Call requires API level 30 (current min is 29): `new android.net.NetworkStats.Entry`"
-        errorLine1="        return new android.net.NetworkStats.Entry("
-        errorLine2="               ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
-        <location
-            file="frameworks/libs/net/common/framework/com/android/net/module/util/NetworkStatsUtils.java"
-            line="120"
-            column="16"/>
-    </issue>
-
-    <issue
-        id="NewApi"
-        message="Call requires API level 30 (current min is 29): `new android.net.NetworkStats`"
-        errorLine1="        android.net.NetworkStats stats = new android.net.NetworkStats(0L, 0);"
-        errorLine2="                                         ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
-        <location
-            file="frameworks/libs/net/common/framework/com/android/net/module/util/NetworkStatsUtils.java"
-            line="108"
-            column="42"/>
-    </issue>
-
-    <issue
-        id="NewApi"
-        message="Call requires API level 31 (current min is 29): `new android.system.NetlinkSocketAddress`"
-        errorLine1="        return new NetlinkSocketAddress(portId, groupsMask);"
-        errorLine2="               ~~~~~~~~~~~~~~~~~~~~~~~~">
-        <location
-            file="frameworks/libs/net/common/device/com/android/net/module/util/SocketUtils.java"
-            line="44"
-            column="16"/>
-    </issue>
-
-</issues>
\ No newline at end of file
diff --git a/staticlibs/native/bpf_headers/BpfMapTest.cpp b/staticlibs/native/bpf_headers/BpfMapTest.cpp
index 10afe86..862114d 100644
--- a/staticlibs/native/bpf_headers/BpfMapTest.cpp
+++ b/staticlibs/native/bpf_headers/BpfMapTest.cpp
@@ -107,12 +107,14 @@
     BpfMap<uint32_t, uint32_t> testMap1;
     checkMapInvalid(testMap1);
 
-    BpfMap<uint32_t, uint32_t> testMap2(BPF_MAP_TYPE_HASH, TEST_MAP_SIZE, BPF_F_NO_PREALLOC);
+    BpfMap<uint32_t, uint32_t> testMap2;
+    ASSERT_RESULT_OK(testMap2.resetMap(BPF_MAP_TYPE_HASH, TEST_MAP_SIZE, BPF_F_NO_PREALLOC));
     checkMapValid(testMap2);
 }
 
 TEST_F(BpfMapTest, basicHelpers) {
-    BpfMap<uint32_t, uint32_t> testMap(BPF_MAP_TYPE_HASH, TEST_MAP_SIZE, BPF_F_NO_PREALLOC);
+    BpfMap<uint32_t, uint32_t> testMap;
+    ASSERT_RESULT_OK(testMap.resetMap(BPF_MAP_TYPE_HASH, TEST_MAP_SIZE, BPF_F_NO_PREALLOC));
     uint32_t key = TEST_KEY1;
     uint32_t value_write = TEST_VALUE1;
     writeToMapAndCheck(testMap, key, value_write);
@@ -126,7 +128,8 @@
 }
 
 TEST_F(BpfMapTest, reset) {
-    BpfMap<uint32_t, uint32_t> testMap(BPF_MAP_TYPE_HASH, TEST_MAP_SIZE, BPF_F_NO_PREALLOC);
+    BpfMap<uint32_t, uint32_t> testMap;
+    ASSERT_RESULT_OK(testMap.resetMap(BPF_MAP_TYPE_HASH, TEST_MAP_SIZE, BPF_F_NO_PREALLOC));
     uint32_t key = TEST_KEY1;
     uint32_t value_write = TEST_VALUE1;
     writeToMapAndCheck(testMap, key, value_write);
@@ -138,7 +141,8 @@
 }
 
 TEST_F(BpfMapTest, moveConstructor) {
-    BpfMap<uint32_t, uint32_t> testMap1(BPF_MAP_TYPE_HASH, TEST_MAP_SIZE, BPF_F_NO_PREALLOC);
+    BpfMap<uint32_t, uint32_t> testMap1;
+    ASSERT_RESULT_OK(testMap1.resetMap(BPF_MAP_TYPE_HASH, TEST_MAP_SIZE, BPF_F_NO_PREALLOC));
     BpfMap<uint32_t, uint32_t> testMap2;
     testMap2 = std::move(testMap1);
     uint32_t key = TEST_KEY1;
@@ -149,7 +153,8 @@
 
 TEST_F(BpfMapTest, SetUpMap) {
     EXPECT_NE(0, access(PINNED_MAP_PATH, R_OK));
-    BpfMap<uint32_t, uint32_t> testMap1(BPF_MAP_TYPE_HASH, TEST_MAP_SIZE, BPF_F_NO_PREALLOC);
+    BpfMap<uint32_t, uint32_t> testMap1;
+    ASSERT_RESULT_OK(testMap1.resetMap(BPF_MAP_TYPE_HASH, TEST_MAP_SIZE, BPF_F_NO_PREALLOC));
     ASSERT_EQ(0, bpfFdPin(testMap1.getMap(), PINNED_MAP_PATH));
     EXPECT_EQ(0, access(PINNED_MAP_PATH, R_OK));
     checkMapValid(testMap1);
@@ -164,7 +169,8 @@
 }
 
 TEST_F(BpfMapTest, iterate) {
-    BpfMap<uint32_t, uint32_t> testMap(BPF_MAP_TYPE_HASH, TEST_MAP_SIZE, BPF_F_NO_PREALLOC);
+    BpfMap<uint32_t, uint32_t> testMap;
+    ASSERT_RESULT_OK(testMap.resetMap(BPF_MAP_TYPE_HASH, TEST_MAP_SIZE, BPF_F_NO_PREALLOC));
     populateMap(TEST_MAP_SIZE, testMap);
     int totalCount = 0;
     int totalSum = 0;
@@ -182,7 +188,8 @@
 }
 
 TEST_F(BpfMapTest, iterateWithValue) {
-    BpfMap<uint32_t, uint32_t> testMap(BPF_MAP_TYPE_HASH, TEST_MAP_SIZE, BPF_F_NO_PREALLOC);
+    BpfMap<uint32_t, uint32_t> testMap;
+    ASSERT_RESULT_OK(testMap.resetMap(BPF_MAP_TYPE_HASH, TEST_MAP_SIZE, BPF_F_NO_PREALLOC));
     populateMap(TEST_MAP_SIZE, testMap);
     int totalCount = 0;
     int totalSum = 0;
@@ -202,7 +209,8 @@
 }
 
 TEST_F(BpfMapTest, mapIsEmpty) {
-    BpfMap<uint32_t, uint32_t> testMap(BPF_MAP_TYPE_HASH, TEST_MAP_SIZE, BPF_F_NO_PREALLOC);
+    BpfMap<uint32_t, uint32_t> testMap;
+    ASSERT_RESULT_OK(testMap.resetMap(BPF_MAP_TYPE_HASH, TEST_MAP_SIZE, BPF_F_NO_PREALLOC));
     expectMapEmpty(testMap);
     uint32_t key = TEST_KEY1;
     uint32_t value_write = TEST_VALUE1;
@@ -232,7 +240,8 @@
 }
 
 TEST_F(BpfMapTest, mapClear) {
-    BpfMap<uint32_t, uint32_t> testMap(BPF_MAP_TYPE_HASH, TEST_MAP_SIZE, BPF_F_NO_PREALLOC);
+    BpfMap<uint32_t, uint32_t> testMap;
+    ASSERT_RESULT_OK(testMap.resetMap(BPF_MAP_TYPE_HASH, TEST_MAP_SIZE));
     populateMap(TEST_MAP_SIZE, testMap);
     Result<bool> isEmpty = testMap.isEmpty();
     ASSERT_RESULT_OK(isEmpty);
diff --git a/staticlibs/native/bpf_headers/include/bpf/BpfClassic.h b/staticlibs/native/bpf_headers/include/bpf/BpfClassic.h
index dd0804c..81be37d 100644
--- a/staticlibs/native/bpf_headers/include/bpf/BpfClassic.h
+++ b/staticlibs/native/bpf_headers/include/bpf/BpfClassic.h
@@ -22,9 +22,15 @@
 // Reject the packet
 #define BPF_REJECT BPF_STMT(BPF_RET | BPF_K, 0)
 
+// Note arguments to BPF_JUMP(opcode, operand, true_offset, false_offset)
+
+// If not equal, jump over count instructions
+#define BPF_JUMP_IF_NOT_EQUAL(v, count) \
+	BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, (v), 0, (count))
+
 // *TWO* instructions: compare and if not equal jump over the accept statement
 #define BPF2_ACCEPT_IF_EQUAL(v) \
-	BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, (v), 0, 1), \
+	BPF_JUMP_IF_NOT_EQUAL((v), 1), \
 	BPF_ACCEPT
 
 // *TWO* instructions: compare and if equal jump over the reject statement
@@ -32,8 +38,24 @@
 	BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, (v), 1, 0), \
 	BPF_REJECT
 
+// *TWO* instructions: compare and if greater or equal jump over the reject statement
+#define BPF2_REJECT_IF_LESS_THAN(v) \
+	BPF_JUMP(BPF_JMP | BPF_JGE | BPF_K, (v), 1, 0), \
+	BPF_REJECT
+
+// *TWO* instructions: compare and if *NOT* greater jump over the reject statement
+#define BPF2_REJECT_IF_GREATER_THAN(v) \
+	BPF_JUMP(BPF_JMP | BPF_JGT | BPF_K, (v), 0, 1), \
+	BPF_REJECT
+
+// *THREE* instructions: compare and if *NOT* in range [lo, hi], jump over the reject statement
+#define BPF3_REJECT_IF_NOT_IN_RANGE(lo, hi) \
+	BPF_JUMP(BPF_JMP | BPF_JGE | BPF_K, (lo), 0, 1), \
+	BPF_JUMP(BPF_JMP | BPF_JGT | BPF_K, (hi), 0, 1), \
+	BPF_REJECT
+
 // *TWO* instructions: compare and if none of the bits are set jump over the reject statement
-#define BPF2_REJECT_IF_ANY_BITS_SET(v) \
+#define BPF2_REJECT_IF_ANY_MASKED_BITS_SET(v) \
 	BPF_JUMP(BPF_JMP | BPF_JSET | BPF_K, (v), 0, 1), \
 	BPF_REJECT
 
@@ -108,3 +130,55 @@
 	  _Static_assert(field_sizeof(struct ipv6hdr, field) == 4, "field of wrong size"); \
 	  offsetof(ipv6hdr, field); \
 	}))
+
+// Load the length of the IPv4 header into X index register.
+// ie. X := 4 * IPv4.IHL, where IPv4.IHL is the bottom nibble
+// of the first byte of the IPv4 (aka network layer) header.
+#define BPF_LOADX_NET_RELATIVE_IPV4_HLEN \
+    BPF_STMT(BPF_LDX | BPF_B | BPF_MSH, (__u32)SKF_NET_OFF)
+
+// Blindly assumes no IPv6 extension headers, just does X := 40
+// You may later adjust this as you parse through IPv6 ext hdrs.
+#define BPF_LOADX_CONSTANT_IPV6_HLEN \
+    BPF_STMT(BPF_LDX | BPF_W | BPF_IMM, sizeof(struct ipv6hdr))
+
+// NOTE: all the following require X to be setup correctly (v4: 20+, v6: 40+)
+
+// 8-bit load from L4 (TCP/UDP/...) header
+#define BPF_LOAD_NETX_RELATIVE_L4_U8(ofs) \
+    BPF_STMT(BPF_LD | BPF_B | BPF_IND, (__u32)SKF_NET_OFF + (ofs))
+
+// Big/Network Endian 16-bit load from L4 (TCP/UDP/...) header
+#define BPF_LOAD_NETX_RELATIVE_L4_BE16(ofs) \
+    BPF_STMT(BPF_LD | BPF_H | BPF_IND, (__u32)SKF_NET_OFF + (ofs))
+
+// Big/Network Endian 32-bit load from L4 (TCP/UDP/...) header
+#define BPF_LOAD_NETX_RELATIVE_L4_BE32(ofs) \
+    BPF_STMT(BPF_LD | BPF_W | BPF_IND, (__u32)SKF_NET_OFF + (ofs))
+
+// Both ICMPv4 and ICMPv6 start with u8 type, u8 code
+#define BPF_LOAD_NETX_RELATIVE_ICMP_TYPE BPF_LOAD_NETX_RELATIVE_L4_U8(0)
+#define BPF_LOAD_NETX_RELATIVE_ICMP_CODE BPF_LOAD_NETX_RELATIVE_L4_U8(1)
+
+// IPv6 extension headers (HOPOPTS, DSTOPS, FRAG) begin with a u8 nexthdr
+#define BPF_LOAD_NETX_RELATIVE_V6EXTHDR_NEXTHDR BPF_LOAD_NETX_RELATIVE_L4_U8(0)
+
+// IPv6 fragment header is always exactly 8 bytes long
+#define BPF_LOAD_CONSTANT_V6FRAGHDR_LEN \
+    BPF_STMT(BPF_LD | BPF_IMM, 8)
+
+// HOPOPTS/DSTOPS follow up with 'u8 len', counting 8 byte units, (0->8, 1->16)
+// *THREE* instructions
+#define BPF3_LOAD_NETX_RELATIVE_V6EXTHDR_LEN \
+    BPF_LOAD_NETX_RELATIVE_L4_U8(1), \
+    BPF_STMT(BPF_ALU | BPF_ADD | BPF_K, 1), \
+    BPF_STMT(BPF_ALU | BPF_LSH | BPF_K, 3)
+
+// *TWO* instructions: A += X; X := A
+#define BPF2_ADD_A_TO_X \
+    BPF_STMT(BPF_ALU | BPF_ADD | BPF_X, 0), \
+    BPF_STMT(BPF_MISC | BPF_TAX, 0)
+
+// UDP/UDPLITE/TCP/SCTP/DCCP all start with be16 srcport, dstport
+#define BPF_LOAD_NETX_RELATIVE_SRC_PORT BPF_LOAD_NETX_RELATIVE_L4_BE16(0)
+#define BPF_LOAD_NETX_RELATIVE_DST_PORT BPF_LOAD_NETX_RELATIVE_L4_BE16(2)
diff --git a/staticlibs/native/bpf_headers/include/bpf/BpfMap.h b/staticlibs/native/bpf_headers/include/bpf/BpfMap.h
index 847083e..5d7eb0d 100644
--- a/staticlibs/native/bpf_headers/include/bpf/BpfMap.h
+++ b/staticlibs/native/bpf_headers/include/bpf/BpfMap.h
@@ -18,10 +18,10 @@
 
 #include <linux/bpf.h>
 
+#include <android/log.h>
 #include <android-base/result.h>
 #include <android-base/stringprintf.h>
 #include <android-base/unique_fd.h>
-#include <utils/Log.h>
 
 #include "BpfSyscallWrappers.h"
 #include "bpf/BpfUtils.h"
@@ -48,41 +48,32 @@
 // is not safe to iterate over a map while another thread or process is deleting
 // from it. In this case the iteration can return duplicate entries.
 template <class Key, class Value>
-class BpfMap {
+class BpfMapRO {
   public:
-    BpfMap<Key, Value>() {};
+    BpfMapRO<Key, Value>() {};
 
     // explicitly force no copy constructor, since it would need to dup the fd
     // (later on, for testing, we still make available a copy assignment operator)
-    BpfMap<Key, Value>(const BpfMap<Key, Value>&) = delete;
+    BpfMapRO<Key, Value>(const BpfMapRO<Key, Value>&) = delete;
 
-  private:
-    void abortOnKeyOrValueSizeMismatch() {
+  protected:
+    void abortOnMismatch(bool writable) const {
         if (!mMapFd.ok()) abort();
         if (isAtLeastKernelVersion(4, 14, 0)) {
+            int flags = bpfGetFdMapFlags(mMapFd);
+            if (flags < 0) abort();
+            if (flags & BPF_F_WRONLY) abort();
+            if (writable && (flags & BPF_F_RDONLY)) abort();
             if (bpfGetFdKeySize(mMapFd) != sizeof(Key)) abort();
             if (bpfGetFdValueSize(mMapFd) != sizeof(Value)) abort();
         }
     }
 
-  protected:
-    // flag must be within BPF_OBJ_FLAG_MASK, ie. 0, BPF_F_RDONLY, BPF_F_WRONLY
-    BpfMap<Key, Value>(const char* pathname, uint32_t flags) {
-        mMapFd.reset(mapRetrieve(pathname, flags));
-        abortOnKeyOrValueSizeMismatch();
-    }
-
   public:
-    explicit BpfMap<Key, Value>(const char* pathname) : BpfMap<Key, Value>(pathname, 0) {}
-
-#ifdef BPF_MAP_MAKE_VISIBLE_FOR_TESTING
-    // All bpf maps should be created by the bpfloader, so this constructor
-    // is reserved for tests
-    BpfMap<Key, Value>(bpf_map_type map_type, uint32_t max_entries, uint32_t map_flags = 0) {
-        mMapFd.reset(createMap(map_type, sizeof(Key), sizeof(Value), max_entries, map_flags));
-        if (!mMapFd.ok()) abort();
+    explicit BpfMapRO<Key, Value>(const char* pathname) {
+        mMapFd.reset(mapRetrieveRO(pathname));
+        abortOnMismatch(/* writable */ false);
     }
-#endif
 
     Result<Key> getFirstKey() const {
         Key firstKey;
@@ -100,13 +91,6 @@
         return nextKey;
     }
 
-    Result<void> writeValue(const Key& key, const Value& value, uint64_t flags) {
-        if (writeToMapEntry(mMapFd, &key, &value, flags)) {
-            return ErrnoErrorf("Write to map {} failed", mMapFd.get());
-        }
-        return {};
-    }
-
     Result<Value> readValue(const Key key) const {
         Value value;
         if (findMapEntry(mMapFd, &key, &value)) {
@@ -115,6 +99,155 @@
         return value;
     }
 
+  protected:
+    [[clang::reinitializes]] Result<void> init(const char* path, int fd, bool writable) {
+        mMapFd.reset(fd);
+        if (!mMapFd.ok()) {
+            return ErrnoErrorf("Pinned map not accessible or does not exist: ({})", path);
+        }
+        // Normally we should return an error here instead of calling abort,
+        // but this cannot happen at runtime without a massive code bug (K/V type mismatch)
+        // and as such it's better to just blow the system up and let the developer fix it.
+        // Crashes are much more likely to be noticed than logs and missing functionality.
+        abortOnMismatch(writable);
+        return {};
+    }
+
+  public:
+    // Function that tries to get map from a pinned path.
+    [[clang::reinitializes]] Result<void> init(const char* path) {
+        return init(path, mapRetrieveRO(path), /* writable */ false);
+    }
+
+    // Iterate through the map and handle each key retrieved based on the filter
+    // without modification of map content.
+    Result<void> iterate(
+            const function<Result<void>(const Key& key,
+                                        const BpfMapRO<Key, Value>& map)>& filter) const;
+
+    // Iterate through the map and get each <key, value> pair, handle each <key,
+    // value> pair based on the filter without modification of map content.
+    Result<void> iterateWithValue(
+            const function<Result<void>(const Key& key, const Value& value,
+                                        const BpfMapRO<Key, Value>& map)>& filter) const;
+
+#ifdef BPF_MAP_MAKE_VISIBLE_FOR_TESTING
+    const unique_fd& getMap() const { return mMapFd; };
+
+    // Copy assignment operator - due to need for fd duping, should not be used in non-test code.
+    BpfMapRO<Key, Value>& operator=(const BpfMapRO<Key, Value>& other) {
+        if (this != &other) mMapFd.reset(fcntl(other.mMapFd.get(), F_DUPFD_CLOEXEC, 0));
+        return *this;
+    }
+#else
+    BpfMapRO<Key, Value>& operator=(const BpfMapRO<Key, Value>&) = delete;
+#endif
+
+    // Move assignment operator
+    BpfMapRO<Key, Value>& operator=(BpfMapRO<Key, Value>&& other) noexcept {
+        if (this != &other) {
+            mMapFd = std::move(other.mMapFd);
+            other.reset();
+        }
+        return *this;
+    }
+
+#ifdef BPF_MAP_MAKE_VISIBLE_FOR_TESTING
+    // Note that unique_fd.reset() carefully saves and restores the errno,
+    // and BpfMap.reset() won't touch the errno if passed in fd is negative either,
+    // hence you can do something like BpfMap.reset(systemcall()) and then
+    // check BpfMap.isValid() and look at errno and see why systemcall() failed.
+    [[clang::reinitializes]] void reset(int fd) {
+        mMapFd.reset(fd);
+        if (mMapFd.ok()) abortOnMismatch(/* writable */ false);  // false isn't ideal
+    }
+
+    // unique_fd has an implicit int conversion defined, which combined with the above
+    // reset(int) would result in double ownership of the fd, hence we either need a custom
+    // implementation of reset(unique_fd), or to delete it and thus cause compile failures
+    // to catch this and prevent it.
+    void reset(unique_fd fd) = delete;
+#endif
+
+    [[clang::reinitializes]] void reset() {
+        mMapFd.reset();
+    }
+
+    bool isValid() const { return mMapFd.ok(); }
+
+    Result<bool> isEmpty() const {
+        auto key = getFirstKey();
+        if (key.ok()) return false;
+        if (key.error().code() == ENOENT) return true;
+        return key.error();
+    }
+
+  protected:
+    unique_fd mMapFd;
+};
+
+template <class Key, class Value>
+Result<void> BpfMapRO<Key, Value>::iterate(
+        const function<Result<void>(const Key& key,
+                                    const BpfMapRO<Key, Value>& map)>& filter) const {
+    Result<Key> curKey = getFirstKey();
+    while (curKey.ok()) {
+        const Result<Key>& nextKey = getNextKey(curKey.value());
+        Result<void> status = filter(curKey.value(), *this);
+        if (!status.ok()) return status;
+        curKey = nextKey;
+    }
+    if (curKey.error().code() == ENOENT) return {};
+    return curKey.error();
+}
+
+template <class Key, class Value>
+Result<void> BpfMapRO<Key, Value>::iterateWithValue(
+        const function<Result<void>(const Key& key, const Value& value,
+                                    const BpfMapRO<Key, Value>& map)>& filter) const {
+    Result<Key> curKey = getFirstKey();
+    while (curKey.ok()) {
+        const Result<Key>& nextKey = getNextKey(curKey.value());
+        Result<Value> curValue = readValue(curKey.value());
+        if (!curValue.ok()) return curValue.error();
+        Result<void> status = filter(curKey.value(), curValue.value(), *this);
+        if (!status.ok()) return status;
+        curKey = nextKey;
+    }
+    if (curKey.error().code() == ENOENT) return {};
+    return curKey.error();
+}
+
+template <class Key, class Value>
+class BpfMap : public BpfMapRO<Key, Value> {
+  protected:
+    using BpfMapRO<Key, Value>::mMapFd;
+    using BpfMapRO<Key, Value>::abortOnMismatch;
+
+  public:
+    using BpfMapRO<Key, Value>::getFirstKey;
+    using BpfMapRO<Key, Value>::getNextKey;
+    using BpfMapRO<Key, Value>::readValue;
+
+    BpfMap<Key, Value>() {};
+
+    explicit BpfMap<Key, Value>(const char* pathname) {
+        mMapFd.reset(mapRetrieveRW(pathname));
+        abortOnMismatch(/* writable */ true);
+    }
+
+    // Function that tries to get map from a pinned path.
+    [[clang::reinitializes]] Result<void> init(const char* path) {
+        return BpfMapRO<Key,Value>::init(path, mapRetrieveRW(path), /* writable */ true);
+    }
+
+    Result<void> writeValue(const Key& key, const Value& value, uint64_t flags) {
+        if (writeToMapEntry(mMapFd, &key, &value, flags)) {
+            return ErrnoErrorf("Write to map {} failed", mMapFd.get());
+        }
+        return {};
+    }
+
     Result<void> deleteValue(const Key& key) {
         if (deleteMapEntry(mMapFd, &key)) {
             return ErrnoErrorf("Delete entry from map {} failed", mMapFd.get());
@@ -122,37 +255,33 @@
         return {};
     }
 
-  protected:
-    [[clang::reinitializes]] Result<void> init(const char* path, int fd) {
-        mMapFd.reset(fd);
-        if (!mMapFd.ok()) {
-            return ErrnoErrorf("Pinned map not accessible or does not exist: ({})", path);
+    Result<void> clear() {
+        while (true) {
+            auto key = getFirstKey();
+            if (!key.ok()) {
+                if (key.error().code() == ENOENT) return {};  // empty: success
+                return key.error();                           // Anything else is an error
+            }
+            auto res = deleteValue(key.value());
+            if (!res.ok()) {
+                // Someone else could have deleted the key, so ignore ENOENT
+                if (res.error().code() == ENOENT) continue;
+                ALOGE("Failed to delete data %s", strerror(res.error().code()));
+                return res.error();
+            }
         }
-        // Normally we should return an error here instead of calling abort,
-        // but this cannot happen at runtime without a massive code bug (K/V type mismatch)
-        // and as such it's better to just blow the system up and let the developer fix it.
-        // Crashes are much more likely to be noticed than logs and missing functionality.
-        abortOnKeyOrValueSizeMismatch();
-        return {};
     }
 
-  public:
-    // Function that tries to get map from a pinned path.
-    [[clang::reinitializes]] Result<void> init(const char* path) {
-        return init(path, mapRetrieveRW(path));
-    }
-
-
 #ifdef BPF_MAP_MAKE_VISIBLE_FOR_TESTING
-    // due to Android SELinux limitations which prevent map creation by anyone besides the bpfloader
-    // this should only ever be used by test code, it is equivalent to:
-    //   .reset(createMap(type, keysize, valuesize, max_entries, map_flags)
-    // TODO: derive map_flags from BpfMap vs BpfMapRO
     [[clang::reinitializes]] Result<void> resetMap(bpf_map_type map_type,
-                                                         uint32_t max_entries,
-                                                         uint32_t map_flags = 0) {
-        mMapFd.reset(createMap(map_type, sizeof(Key), sizeof(Value), max_entries, map_flags));
+                                                   uint32_t max_entries,
+                                                   uint32_t map_flags = 0) {
+        if (map_flags & BPF_F_WRONLY) abort();
+        if (map_flags & BPF_F_RDONLY) abort();
+        mMapFd.reset(createMap(map_type, sizeof(Key), sizeof(Value), max_entries,
+                               map_flags));
         if (!mMapFd.ok()) return ErrnoErrorf("Unable to create map.");
+        abortOnMismatch(/* writable */ true);
         return {};
     }
 #endif
@@ -180,72 +309,6 @@
             const function<Result<void>(const Key& key, const Value& value,
                                         BpfMap<Key, Value>& map)>& filter);
 
-#ifdef BPF_MAP_MAKE_VISIBLE_FOR_TESTING
-    const unique_fd& getMap() const { return mMapFd; };
-
-    // Copy assignment operator - due to need for fd duping, should not be used in non-test code.
-    BpfMap<Key, Value>& operator=(const BpfMap<Key, Value>& other) {
-        if (this != &other) mMapFd.reset(fcntl(other.mMapFd.get(), F_DUPFD_CLOEXEC, 0));
-        return *this;
-    }
-#else
-    BpfMap<Key, Value>& operator=(const BpfMap<Key, Value>&) = delete;
-#endif
-
-    // Move assignment operator
-    BpfMap<Key, Value>& operator=(BpfMap<Key, Value>&& other) noexcept {
-        if (this != &other) {
-            mMapFd = std::move(other.mMapFd);
-            other.reset();
-        }
-        return *this;
-    }
-
-    void reset(unique_fd fd) = delete;
-
-#ifdef BPF_MAP_MAKE_VISIBLE_FOR_TESTING
-    // Note that unique_fd.reset() carefully saves and restores the errno,
-    // and BpfMap.reset() won't touch the errno if passed in fd is negative either,
-    // hence you can do something like BpfMap.reset(systemcall()) and then
-    // check BpfMap.isValid() and look at errno and see why systemcall() failed.
-    [[clang::reinitializes]] void reset(int fd) {
-        mMapFd.reset(fd);
-        if (mMapFd.ok()) abortOnKeyOrValueSizeMismatch();
-    }
-#endif
-
-    [[clang::reinitializes]] void reset() {
-        mMapFd.reset();
-    }
-
-    bool isValid() const { return mMapFd.ok(); }
-
-    Result<void> clear() {
-        while (true) {
-            auto key = getFirstKey();
-            if (!key.ok()) {
-                if (key.error().code() == ENOENT) return {};  // empty: success
-                return key.error();                           // Anything else is an error
-            }
-            auto res = deleteValue(key.value());
-            if (!res.ok()) {
-                // Someone else could have deleted the key, so ignore ENOENT
-                if (res.error().code() == ENOENT) continue;
-                ALOGE("Failed to delete data %s", strerror(res.error().code()));
-                return res.error();
-            }
-        }
-    }
-
-    Result<bool> isEmpty() const {
-        auto key = getFirstKey();
-        if (key.ok()) return false;
-        if (key.error().code() == ENOENT) return true;
-        return key.error();
-    }
-
-  private:
-    unique_fd mMapFd;
 };
 
 template <class Key, class Value>
@@ -312,19 +375,5 @@
     return curKey.error();
 }
 
-template <class Key, class Value>
-class BpfMapRO : public BpfMap<Key, Value> {
-  public:
-    BpfMapRO<Key, Value>() {};
-
-    explicit BpfMapRO<Key, Value>(const char* pathname)
-        : BpfMap<Key, Value>(pathname, BPF_F_RDONLY) {}
-
-    // Function that tries to get map from a pinned path.
-    [[clang::reinitializes]] Result<void> init(const char* path) {
-        return BpfMap<Key,Value>::init(path, mapRetrieveRO(path));
-    }
-};
-
 }  // namespace bpf
 }  // namespace android
diff --git a/staticlibs/native/bpf_headers/include/bpf/bpf_helpers.h b/staticlibs/native/bpf_headers/include/bpf/bpf_helpers.h
index 67ac0e4..baff09b 100644
--- a/staticlibs/native/bpf_headers/include/bpf/bpf_helpers.h
+++ b/staticlibs/native/bpf_headers/include/bpf/bpf_helpers.h
@@ -105,9 +105,19 @@
  * implemented in the kernel sources.
  */
 
-#define KVER_NONE 0
-#define KVER(a, b, c) (((a) << 24) + ((b) << 16) + (c))
-#define KVER_INF 0xFFFFFFFFu
+struct kver_uint { unsigned int kver; };
+#define KVER_(v) ((struct kver_uint){ .kver = (v) })
+#define KVER(a, b, c) KVER_(((a) << 24) + ((b) << 16) + (c))
+#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_15 KVER(5, 15, 0)
+#define KVER_INF KVER_(0xFFFFFFFFu)
+
+#define KVER_IS_AT_LEAST(kver, a, b, c) ((kver).kver >= KVER(a, b, c).kver)
 
 /*
  * BPFFS (ie. /sys/fs/bpf) labelling is as follows:
@@ -188,10 +198,12 @@
         __attribute__ ((section(".maps." #name), used)) \
                 ____btf_map_##name = { }
 
-#define BPF_ASSERT_LOADER_VERSION(min_loader, ignore_eng, ignore_user, ignore_userdebug)  \
-    _Static_assert(                                                                       \
-        (min_loader) >= BPFLOADER_IGNORED_ON_VERSION ||                                   \
-            !((ignore_eng) || (ignore_user) || (ignore_userdebug)),                       \
+#define BPF_ASSERT_LOADER_VERSION(min_loader, ignore_eng, ignore_user, ignore_userdebug) \
+    _Static_assert(                                                                      \
+        (min_loader) >= BPFLOADER_IGNORED_ON_VERSION ||                                  \
+            !((ignore_eng).ignore_on_eng ||                                              \
+              (ignore_user).ignore_on_user ||                                            \
+              (ignore_userdebug).ignore_on_userdebug),                                   \
         "bpfloader min version must be >= 0.33 in order to use ignored_on");
 
 #define DEFINE_BPF_MAP_BASE(the_map, TYPE, keysize, valuesize, num_entries, \
@@ -209,14 +221,14 @@
         .mode = (md),                                                       \
         .bpfloader_min_ver = (minloader),                                   \
         .bpfloader_max_ver = (maxloader),                                   \
-        .min_kver = (minkver),                                              \
-        .max_kver = (maxkver),                                              \
+        .min_kver = (minkver).kver,                                         \
+        .max_kver = (maxkver).kver,                                         \
         .selinux_context = (selinux),                                       \
         .pin_subdir = (pindir),                                             \
-        .shared = (share),                                                  \
-        .ignore_on_eng = (ignore_eng),                                      \
-        .ignore_on_user = (ignore_user),                                    \
-        .ignore_on_userdebug = (ignore_userdebug),                          \
+        .shared = (share).shared,                                           \
+        .ignore_on_eng = (ignore_eng).ignore_on_eng,                        \
+        .ignore_on_user = (ignore_user).ignore_on_user,                     \
+        .ignore_on_userdebug = (ignore_userdebug).ignore_on_userdebug,      \
     };                                                                      \
     BPF_ASSERT_LOADER_VERSION(minloader, ignore_eng, ignore_user, ignore_userdebug);
 
@@ -230,7 +242,7 @@
                                selinux, pindir, share, min_loader, max_loader, \
                                ignore_eng, ignore_user, ignore_userdebug)      \
     DEFINE_BPF_MAP_BASE(the_map, RINGBUF, 0, 0, size_bytes, usr, grp, md,      \
-                        selinux, pindir, share, KVER(5, 8, 0), KVER_INF,       \
+                        selinux, pindir, share, KVER_5_8, KVER_INF,            \
                         min_loader, max_loader, ignore_eng, ignore_user,       \
                         ignore_userdebug);                                     \
                                                                                \
@@ -312,11 +324,11 @@
 #error "Bpf Map UID must be left at default of AID_ROOT for BpfLoader prior to v0.28"
 #endif
 
-#define DEFINE_BPF_MAP_UGM(the_map, TYPE, KeyType, ValueType, num_entries, usr, grp, md)   \
-    DEFINE_BPF_MAP_EXT(the_map, TYPE, KeyType, ValueType, num_entries, usr, grp, md,       \
-                       DEFAULT_BPF_MAP_SELINUX_CONTEXT, DEFAULT_BPF_MAP_PIN_SUBDIR, false, \
-                       BPFLOADER_MIN_VER, BPFLOADER_MAX_VER, /*ignore_on_eng*/false,       \
-                       /*ignore_on_user*/false, /*ignore_on_userdebug*/false)
+#define DEFINE_BPF_MAP_UGM(the_map, TYPE, KeyType, ValueType, num_entries, usr, grp, md)     \
+    DEFINE_BPF_MAP_EXT(the_map, TYPE, KeyType, ValueType, num_entries, usr, grp, md,         \
+                       DEFAULT_BPF_MAP_SELINUX_CONTEXT, DEFAULT_BPF_MAP_PIN_SUBDIR, PRIVATE, \
+                       BPFLOADER_MIN_VER, BPFLOADER_MAX_VER, LOAD_ON_ENG,                    \
+                       LOAD_ON_USER, LOAD_ON_USERDEBUG)
 
 #define DEFINE_BPF_MAP(the_map, TYPE, KeyType, ValueType, num_entries) \
     DEFINE_BPF_MAP_UGM(the_map, TYPE, KeyType, ValueType, num_entries, \
@@ -362,16 +374,16 @@
     const struct bpf_prog_def SECTION("progs") the_prog##_def = {                        \
         .uid = (prog_uid),                                                               \
         .gid = (prog_gid),                                                               \
-        .min_kver = (min_kv),                                                            \
-        .max_kver = (max_kv),                                                            \
-        .optional = (opt),                                                               \
+        .min_kver = (min_kv).kver,                                                       \
+        .max_kver = (max_kv).kver,                                                       \
+        .optional = (opt).optional,                                                      \
         .bpfloader_min_ver = (min_loader),                                               \
         .bpfloader_max_ver = (max_loader),                                               \
         .selinux_context = (selinux),                                                    \
         .pin_subdir = (pindir),                                                          \
-        .ignore_on_eng = (ignore_eng),                                                   \
-        .ignore_on_user = (ignore_user),                                                 \
-        .ignore_on_userdebug = (ignore_userdebug),                                       \
+        .ignore_on_eng = (ignore_eng).ignore_on_eng,                                     \
+        .ignore_on_user = (ignore_user).ignore_on_user,                                  \
+        .ignore_on_userdebug = (ignore_userdebug).ignore_on_userdebug,                   \
     };                                                                                   \
     SECTION(SECTION_NAME)                                                                \
     int the_prog
@@ -389,7 +401,7 @@
     DEFINE_BPF_PROG_EXT(SECTION_NAME, prog_uid, prog_gid, the_prog, min_kv, max_kv,                \
                         BPFLOADER_MIN_VER, BPFLOADER_MAX_VER, opt,                                 \
                         DEFAULT_BPF_PROG_SELINUX_CONTEXT, DEFAULT_BPF_PROG_PIN_SUBDIR,             \
-                        false, false, false)
+                        LOAD_ON_ENG, LOAD_ON_USER, LOAD_ON_USERDEBUG)
 
 // Programs (here used in the sense of functions/sections) marked optional are allowed to fail
 // to load (for example due to missing kernel patches).
@@ -405,21 +417,24 @@
 // programs requiring a kernel version >= min_kv && < max_kv
 #define DEFINE_BPF_PROG_KVER_RANGE(SECTION_NAME, prog_uid, prog_gid, the_prog, min_kv, max_kv) \
     DEFINE_BPF_PROG_KVER_RANGE_OPT(SECTION_NAME, prog_uid, prog_gid, the_prog, min_kv, max_kv, \
-                                   false)
+                                   MANDATORY)
 #define DEFINE_OPTIONAL_BPF_PROG_KVER_RANGE(SECTION_NAME, prog_uid, prog_gid, the_prog, min_kv, \
                                             max_kv)                                             \
-    DEFINE_BPF_PROG_KVER_RANGE_OPT(SECTION_NAME, prog_uid, prog_gid, the_prog, min_kv, max_kv, true)
+    DEFINE_BPF_PROG_KVER_RANGE_OPT(SECTION_NAME, prog_uid, prog_gid, the_prog, min_kv, max_kv, \
+                                   OPTIONAL)
 
 // programs requiring a kernel version >= min_kv
 #define DEFINE_BPF_PROG_KVER(SECTION_NAME, prog_uid, prog_gid, the_prog, min_kv)                 \
     DEFINE_BPF_PROG_KVER_RANGE_OPT(SECTION_NAME, prog_uid, prog_gid, the_prog, min_kv, KVER_INF, \
-                                   false)
+                                   MANDATORY)
 #define DEFINE_OPTIONAL_BPF_PROG_KVER(SECTION_NAME, prog_uid, prog_gid, the_prog, min_kv)        \
     DEFINE_BPF_PROG_KVER_RANGE_OPT(SECTION_NAME, prog_uid, prog_gid, the_prog, min_kv, KVER_INF, \
-                                   true)
+                                   OPTIONAL)
 
 // programs with no kernel version requirements
 #define DEFINE_BPF_PROG(SECTION_NAME, prog_uid, prog_gid, the_prog) \
-    DEFINE_BPF_PROG_KVER_RANGE_OPT(SECTION_NAME, prog_uid, prog_gid, the_prog, 0, KVER_INF, false)
+    DEFINE_BPF_PROG_KVER_RANGE_OPT(SECTION_NAME, prog_uid, prog_gid, the_prog, KVER_NONE, KVER_INF, \
+                                   MANDATORY)
 #define DEFINE_OPTIONAL_BPF_PROG(SECTION_NAME, prog_uid, prog_gid, the_prog) \
-    DEFINE_BPF_PROG_KVER_RANGE_OPT(SECTION_NAME, prog_uid, prog_gid, the_prog, 0, KVER_INF, true)
+    DEFINE_BPF_PROG_KVER_RANGE_OPT(SECTION_NAME, prog_uid, prog_gid, the_prog, KVER_NONE, KVER_INF, \
+                                   OPTIONAL)
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 e7428b6..ef03c4d 100644
--- a/staticlibs/native/bpf_headers/include/bpf/bpf_map_def.h
+++ b/staticlibs/native/bpf_headers/include/bpf/bpf_map_def.h
@@ -114,6 +114,31 @@
 // BPF wants 8, but 32-bit x86 wants 4
 //_Static_assert(_Alignof(unsigned long long) == 8, "_Alignof unsigned long long != 8");
 
+
+// for maps:
+struct shared_bool { bool shared; };
+#define PRIVATE ((struct shared_bool){ .shared = false })
+#define SHARED ((struct shared_bool){ .shared = true })
+
+// for programs:
+struct optional_bool { bool optional; };
+#define MANDATORY ((struct optional_bool){ .optional = false })
+#define OPTIONAL ((struct optional_bool){ .optional = true })
+
+// for both maps and programs:
+struct ignore_on_eng_bool { bool ignore_on_eng; };
+#define LOAD_ON_ENG ((struct ignore_on_eng_bool){ .ignore_on_eng = false })
+#define IGNORE_ON_ENG ((struct ignore_on_eng_bool){ .ignore_on_eng = true })
+
+struct ignore_on_user_bool { bool ignore_on_user; };
+#define LOAD_ON_USER ((struct ignore_on_user_bool){ .ignore_on_user = false })
+#define IGNORE_ON_USER ((struct ignore_on_user_bool){ .ignore_on_user = true })
+
+struct ignore_on_userdebug_bool { bool ignore_on_userdebug; };
+#define LOAD_ON_USERDEBUG ((struct ignore_on_userdebug_bool){ .ignore_on_userdebug = false })
+#define IGNORE_ON_USERDEBUG ((struct ignore_on_userdebug_bool){ .ignore_on_userdebug = true })
+
+
 // Length of strings (incl. selinux_context and pin_subdir)
 // in the bpf_map_def and bpf_prog_def structs.
 //
diff --git a/staticlibs/native/bpf_syscall_wrappers/include/BpfSyscallWrappers.h b/staticlibs/native/bpf_syscall_wrappers/include/BpfSyscallWrappers.h
index 13f7cb3..9995cb9 100644
--- a/staticlibs/native/bpf_syscall_wrappers/include/BpfSyscallWrappers.h
+++ b/staticlibs/native/bpf_syscall_wrappers/include/BpfSyscallWrappers.h
@@ -44,6 +44,11 @@
     return syscall(__NR_bpf, cmd, &attr, sizeof(attr));
 }
 
+// this version is meant for use with cmd's which mutate the argument
+inline int bpf(enum bpf_cmd cmd, bpf_attr *attr) {
+    return syscall(__NR_bpf, cmd, attr, sizeof(*attr));
+}
+
 inline int createMap(bpf_map_type map_type, uint32_t key_size, uint32_t value_size,
                      uint32_t max_entries, uint32_t map_flags) {
     return bpf(BPF_MAP_CREATE, {
@@ -160,6 +165,27 @@
                                 });
 }
 
+inline int queryProgram(const BPF_FD_TYPE cg_fd,
+                        enum bpf_attach_type attach_type,
+                        __u32 query_flags = 0,
+                        __u32 attach_flags = 0) {
+    int prog_id = -1;  // equivalent to an array of one integer.
+    bpf_attr arg = {
+            .query = {
+                    .target_fd = BPF_FD_TO_U32(cg_fd),
+                    .attach_type = attach_type,
+                    .query_flags = query_flags,
+                    .attach_flags = attach_flags,
+                    .prog_ids = ptr_to_u64(&prog_id),  // pointer to output array
+                    .prog_cnt = 1,  // in: space - nr of ints in the array, out: used
+            }
+    };
+    int v = bpf(BPF_PROG_QUERY, &arg);
+    if (v) return v;  // error case
+    if (!arg.query.prog_cnt) return 0;  // no program, kernel never returns zero id
+    return prog_id;  // return actual id
+}
+
 inline int detachSingleProgram(bpf_attach_type type, const BPF_FD_TYPE prog_fd,
                                const BPF_FD_TYPE cg_fd) {
     return bpf(BPF_PROG_DETACH, {
diff --git a/staticlibs/native/bpfmapjni/com_android_net_module_util_TcUtils.cpp b/staticlibs/native/bpfmapjni/com_android_net_module_util_TcUtils.cpp
index cb06afb..ab83da6 100644
--- a/staticlibs/native/bpfmapjni/com_android_net_module_util_TcUtils.cpp
+++ b/staticlibs/native/bpfmapjni/com_android_net_module_util_TcUtils.cpp
@@ -27,7 +27,7 @@
 }
 
 static jboolean com_android_net_module_util_TcUtils_isEthernet(JNIEnv *env,
-                                                               jobject clazz,
+                                                               jclass clazz,
                                                                jstring iface) {
   ScopedUtfChars interface(env, iface);
   bool result = false;
@@ -43,7 +43,7 @@
 // tc filter add dev .. in/egress prio 1 protocol ipv6/ip bpf object-pinned
 // /sys/fs/bpf/... direct-action
 static void com_android_net_module_util_TcUtils_tcFilterAddDevBpf(
-    JNIEnv *env, jobject clazz, jint ifIndex, jboolean ingress, jshort prio,
+    JNIEnv *env, jclass clazz, jint ifIndex, jboolean ingress, jshort prio,
     jshort proto, jstring bpfProgPath) {
   ScopedUtfChars pathname(env, bpfProgPath);
   int error = tcAddBpfFilter(ifIndex, ingress, prio, proto, pathname.c_str());
@@ -59,7 +59,7 @@
 //     action bpf object-pinned .. \
 //     drop
 static void com_android_net_module_util_TcUtils_tcFilterAddDevIngressPolice(
-    JNIEnv *env, jobject clazz, jint ifIndex, jshort prio, jshort proto,
+    JNIEnv *env, jclass clazz, jint ifIndex, jshort prio, jshort proto,
     jint rateInBytesPerSec, jstring bpfProgPath) {
   ScopedUtfChars pathname(env, bpfProgPath);
   int error = tcAddIngressPoliceFilter(ifIndex, prio, proto, rateInBytesPerSec,
@@ -74,7 +74,7 @@
 
 // tc filter del dev .. in/egress prio .. protocol ..
 static void com_android_net_module_util_TcUtils_tcFilterDelDev(
-    JNIEnv *env, jobject clazz, jint ifIndex, jboolean ingress, jshort prio,
+    JNIEnv *env, jclass clazz, jint ifIndex, jboolean ingress, jshort prio,
     jshort proto) {
   int error = tcDeleteFilter(ifIndex, ingress, prio, proto);
   if (error) {
@@ -86,7 +86,7 @@
 
 // tc qdisc add dev .. clsact
 static void com_android_net_module_util_TcUtils_tcQdiscAddDevClsact(JNIEnv *env,
-                                                                    jobject clazz,
+                                                                    jclass clazz,
                                                                     jint ifIndex) {
   int error = tcAddQdiscClsact(ifIndex);
   if (error) {
diff --git a/staticlibs/native/bpfutiljni/com_android_net_module_util_BpfUtils.cpp b/staticlibs/native/bpfutiljni/com_android_net_module_util_BpfUtils.cpp
index 0f2ebbd..bcc3ded 100644
--- a/staticlibs/native/bpfutiljni/com_android_net_module_util_BpfUtils.cpp
+++ b/staticlibs/native/bpfutiljni/com_android_net_module_util_BpfUtils.cpp
@@ -30,9 +30,8 @@
 
 using base::unique_fd;
 
-// If attach fails throw error and return false.
-static jboolean com_android_net_module_util_BpfUtil_attachProgramToCgroup(JNIEnv *env,
-        jobject clazz, jint type, jstring bpfProgPath, jstring cgroupPath, jint flags) {
+static jint com_android_net_module_util_BpfUtil_getProgramIdFromCgroup(JNIEnv *env,
+        jclass clazz, jint type, jstring cgroupPath) {
 
     ScopedUtfChars dirPath(env, cgroupPath);
     unique_fd cg_fd(open(dirPath.c_str(), O_DIRECTORY | O_RDONLY | O_CLOEXEC));
@@ -40,87 +39,27 @@
         jniThrowExceptionFmt(env, "java/io/IOException",
                              "Failed to open the cgroup directory %s: %s",
                              dirPath.c_str(), strerror(errno));
-        return false;
+        return -1;
     }
 
-    ScopedUtfChars bpfProg(env, bpfProgPath);
-    unique_fd bpf_fd(bpf::retrieveProgram(bpfProg.c_str()));
-    if (bpf_fd == -1) {
+    int id = bpf::queryProgram(cg_fd, (bpf_attach_type) type);
+    if (id < 0) {
         jniThrowExceptionFmt(env, "java/io/IOException",
-                             "Failed to retrieve bpf program from %s: %s",
-                             bpfProg.c_str(), strerror(errno));
-        return false;
+                             "Failed to query bpf program %d at %s: %s",
+                             type, dirPath.c_str(), strerror(errno));
+        return -1;
     }
-    if (bpf::attachProgram((bpf_attach_type) type, bpf_fd, cg_fd, flags)) {
-        jniThrowExceptionFmt(env, "java/io/IOException",
-                             "Failed to attach bpf program %s to %s: %s",
-                             bpfProg.c_str(), dirPath.c_str(), strerror(errno));
-        return false;
-    }
-    return true;
+    return id;  // may return 0 meaning none
 }
 
-// If detach fails throw error and return false.
-static jboolean com_android_net_module_util_BpfUtil_detachProgramFromCgroup(JNIEnv *env,
-        jobject clazz, jint type, jstring cgroupPath) {
-
-    ScopedUtfChars dirPath(env, cgroupPath);
-    unique_fd cg_fd(open(dirPath.c_str(), O_DIRECTORY | O_RDONLY | O_CLOEXEC));
-    if (cg_fd == -1) {
-        jniThrowExceptionFmt(env, "java/io/IOException",
-                             "Failed to open the cgroup directory %s: %s",
-                             dirPath.c_str(), strerror(errno));
-        return false;
-    }
-
-    if (bpf::detachProgram((bpf_attach_type) type, cg_fd)) {
-        jniThrowExceptionFmt(env, "Failed to detach bpf program from %s: %s",
-                dirPath.c_str(), strerror(errno));
-        return false;
-    }
-    return true;
-}
-
-// If detach single program fails throw error and return false.
-static jboolean com_android_net_module_util_BpfUtil_detachSingleProgramFromCgroup(JNIEnv *env,
-        jobject clazz, jint type, jstring bpfProgPath, jstring cgroupPath) {
-
-    ScopedUtfChars dirPath(env, cgroupPath);
-    unique_fd cg_fd(open(dirPath.c_str(), O_DIRECTORY | O_RDONLY | O_CLOEXEC));
-    if (cg_fd == -1) {
-        jniThrowExceptionFmt(env, "java/io/IOException",
-                             "Failed to open the cgroup directory %s: %s",
-                             dirPath.c_str(), strerror(errno));
-        return false;
-    }
-
-    ScopedUtfChars bpfProg(env, bpfProgPath);
-    unique_fd bpf_fd(bpf::retrieveProgram(bpfProg.c_str()));
-    if (bpf_fd == -1) {
-        jniThrowExceptionFmt(env, "java/io/IOException",
-                             "Failed to retrieve bpf program from %s: %s",
-                             bpfProg.c_str(), strerror(errno));
-        return false;
-    }
-    if (bpf::detachSingleProgram((bpf_attach_type) type, bpf_fd, cg_fd)) {
-        jniThrowExceptionFmt(env, "Failed to detach bpf program %s from %s: %s",
-                bpfProg.c_str(), dirPath.c_str(), strerror(errno));
-        return false;
-    }
-    return true;
-}
 
 /*
  * JNI registration.
  */
 static const JNINativeMethod gMethods[] = {
     /* name, signature, funcPtr */
-    { "native_attachProgramToCgroup", "(ILjava/lang/String;Ljava/lang/String;I)Z",
-        (void*) com_android_net_module_util_BpfUtil_attachProgramToCgroup },
-    { "native_detachProgramFromCgroup", "(ILjava/lang/String;)Z",
-        (void*) com_android_net_module_util_BpfUtil_detachProgramFromCgroup },
-    { "native_detachSingleProgramFromCgroup", "(ILjava/lang/String;Ljava/lang/String;)Z",
-        (void*) com_android_net_module_util_BpfUtil_detachSingleProgramFromCgroup },
+    { "native_getProgramIdFromCgroup", "(ILjava/lang/String;)I",
+        (void*) com_android_net_module_util_BpfUtil_getProgramIdFromCgroup },
 };
 
 int register_com_android_net_module_util_BpfUtils(JNIEnv* env, char const* class_name) {
diff --git a/staticlibs/native/netjniutils/Android.bp b/staticlibs/native/netjniutils/Android.bp
index 22fd1fa..ca3bbbc 100644
--- a/staticlibs/native/netjniutils/Android.bp
+++ b/staticlibs/native/netjniutils/Android.bp
@@ -31,8 +31,8 @@
         "-Werror",
         "-Wno-unused-parameter",
     ],
-    sdk_version: "29",
-    min_sdk_version: "29",
+    sdk_version: "30",
+    min_sdk_version: "30",
     apex_available: [
         "//apex_available:anyapex",
         "//apex_available:platform",
diff --git a/staticlibs/netd/Android.bp b/staticlibs/netd/Android.bp
index d135a1c..65b3b09 100644
--- a/staticlibs/netd/Android.bp
+++ b/staticlibs/netd/Android.bp
@@ -19,7 +19,7 @@
 java_library {
     name: "netd_aidl_interface-lateststable-java",
     sdk_version: "system_current",
-    min_sdk_version: "29",
+    min_sdk_version: "30",
     static_libs: [
         "netd_aidl_interface-V13-java",
     ],
@@ -38,7 +38,7 @@
     apex_available: [
         "com.android.resolv",
     ],
-    min_sdk_version: "29",
+    min_sdk_version: "30",
 }
 
 cc_library_static {
@@ -50,7 +50,7 @@
         "com.android.resolv",
         "com.android.tethering",
     ],
-    min_sdk_version: "29",
+    min_sdk_version: "30",
 }
 
 cc_defaults {
@@ -96,17 +96,17 @@
                 "com.android.tethering",
                 "com.android.wifi",
             ],
-            // this is part of updatable modules(NetworkStack) which targets 29(Q)
-            min_sdk_version: "29",
+            // this is part of updatable modules(NetworkStack) which targets 30(R)
+            min_sdk_version: "30",
         },
         ndk: {
             apex_available: [
                 "//apex_available:platform",
                 "com.android.tethering",
             ],
-            // This is necessary for the DnsResovler tests to run in Android Q.
-            // Soong would recognize this value and produce the Q compatible aidl library.
-            min_sdk_version: "29",
+            // This is necessary for the DnsResovler tests to run in Android R.
+            // Soong would recognize this value and produce the R compatible aidl library.
+            min_sdk_version: "30",
         },
     },
     versions_with_info: [
@@ -170,7 +170,7 @@
 java_library {
     name: "netd_event_listener_interface-lateststable-java",
     sdk_version: "system_current",
-    min_sdk_version: "29",
+    min_sdk_version: "30",
     static_libs: [
         "netd_event_listener_interface-V1-java",
     ],
@@ -194,7 +194,7 @@
                 "//apex_available:platform",
                 "com.android.resolv",
             ],
-            min_sdk_version: "29",
+            min_sdk_version: "30",
         },
         java: {
             apex_available: [
@@ -202,7 +202,7 @@
                 "com.android.wifi",
                 "com.android.tethering",
             ],
-            min_sdk_version: "29",
+            min_sdk_version: "30",
         },
     },
     versions_with_info: [
diff --git a/staticlibs/netd/libnetdutils/Android.bp b/staticlibs/netd/libnetdutils/Android.bp
index 3169033..fdb9380 100644
--- a/staticlibs/netd/libnetdutils/Android.bp
+++ b/staticlibs/netd/libnetdutils/Android.bp
@@ -40,7 +40,7 @@
         "com.android.resolv",
         "com.android.tethering",
     ],
-    min_sdk_version: "29",
+    min_sdk_version: "30",
 }
 
 cc_test {
diff --git a/staticlibs/netd/libnetdutils/Utils.cpp b/staticlibs/netd/libnetdutils/Utils.cpp
index 16ec882..9b0b3e0 100644
--- a/staticlibs/netd/libnetdutils/Utils.cpp
+++ b/staticlibs/netd/libnetdutils/Utils.cpp
@@ -16,6 +16,7 @@
  */
 
 #include <map>
+#include <vector>
 
 #include <net/if.h>
 
diff --git a/staticlibs/tests/unit/Android.bp b/staticlibs/tests/unit/Android.bp
index 40371e6..031e52f 100644
--- a/staticlibs/tests/unit/Android.bp
+++ b/staticlibs/tests/unit/Android.bp
@@ -9,7 +9,7 @@
 android_library {
     name: "NetworkStaticLibTestsLib",
     srcs: ["src/**/*.java","src/**/*.kt"],
-    min_sdk_version: "29",
+    min_sdk_version: "30",
     defaults: ["framework-connectivity-test-defaults"],
     static_libs: [
         "androidx.test.rules",
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 5a96bcb..06b3e2f 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
@@ -71,6 +71,10 @@
 public class DeviceConfigUtilsTest {
     private static final String TEST_NAME_SPACE = "connectivity";
     private static final String TEST_EXPERIMENT_FLAG = "experiment_flag";
+    private static final String CORE_NETWORKING_TRUNK_STABLE_NAMESPACE = "android_core_networking";
+    private static final String TEST_TRUNK_STABLE_FLAG = "trunk_stable_feature";
+    private static final String TEST_CORE_NETWORKING_TRUNK_STABLE_FLAG_PROPERTY =
+            "com.android.net.flags.trunk_stable_feature";
     private static final int TEST_FLAG_VALUE = 28;
     private static final String TEST_FLAG_VALUE_STRING = "28";
     private static final int TEST_DEFAULT_FLAG_VALUE = 0;
@@ -228,27 +232,57 @@
     }
 
     @Test
-    public void testIsNetworkStackFeatureEnabled() {
+    public void testIsFeatureEnabled() {
         doReturn(TEST_FLAG_VALUE_STRING).when(() -> DeviceConfig.getProperty(NAMESPACE_CONNECTIVITY,
                 TEST_EXPERIMENT_FLAG));
-        assertTrue(DeviceConfigUtils.isNetworkStackFeatureEnabled(mContext, TEST_EXPERIMENT_FLAG));
-    }
-
-    @Test
-    public void testIsTetheringFeatureEnabled() {
         doReturn(TEST_FLAG_VALUE_STRING).when(() -> DeviceConfig.getProperty(NAMESPACE_TETHERING,
                 TEST_EXPERIMENT_FLAG));
+        assertTrue(DeviceConfigUtils.isNetworkStackFeatureEnabled(mContext, TEST_EXPERIMENT_FLAG));
         assertTrue(DeviceConfigUtils.isTetheringFeatureEnabled(mContext, TEST_EXPERIMENT_FLAG));
     }
-
     @Test
-    public void testFeatureDefaultEnabled() {
+    public void testIsFeatureEnabledFeatureDefaultDisabled() throws Exception {
         doReturn(null).when(() -> DeviceConfig.getProperty(NAMESPACE_CONNECTIVITY,
                 TEST_EXPERIMENT_FLAG));
         doReturn(null).when(() -> DeviceConfig.getProperty(NAMESPACE_TETHERING,
                 TEST_EXPERIMENT_FLAG));
         assertFalse(DeviceConfigUtils.isNetworkStackFeatureEnabled(mContext, TEST_EXPERIMENT_FLAG));
         assertFalse(DeviceConfigUtils.isTetheringFeatureEnabled(mContext, TEST_EXPERIMENT_FLAG));
+
+        // If the flag is unset, package info is not queried
+        verify(mContext, never()).getPackageManager();
+        verify(mContext, never()).getPackageName();
+        verify(mPm, never()).getPackageInfo(anyString(), anyInt());
+    }
+
+    @Test
+    public void testIsFeatureEnabledFeatureForceEnabled() throws Exception {
+        doReturn("1").when(() -> DeviceConfig.getProperty(NAMESPACE_CONNECTIVITY,
+                TEST_EXPERIMENT_FLAG));
+        doReturn("1").when(() -> DeviceConfig.getProperty(NAMESPACE_TETHERING,
+                TEST_EXPERIMENT_FLAG));
+        assertTrue(DeviceConfigUtils.isNetworkStackFeatureEnabled(mContext, TEST_EXPERIMENT_FLAG));
+        assertTrue(DeviceConfigUtils.isTetheringFeatureEnabled(mContext, TEST_EXPERIMENT_FLAG));
+
+        // If the feature is force enabled, package info is not queried
+        verify(mContext, never()).getPackageManager();
+        verify(mContext, never()).getPackageName();
+        verify(mPm, never()).getPackageInfo(anyString(), anyInt());
+    }
+
+    @Test
+    public void testIsFeatureEnabledFeatureForceDisabled() throws Exception {
+        doReturn("-1").when(() -> DeviceConfig.getProperty(NAMESPACE_CONNECTIVITY,
+                TEST_EXPERIMENT_FLAG));
+        doReturn("-1").when(() -> DeviceConfig.getProperty(NAMESPACE_TETHERING,
+                TEST_EXPERIMENT_FLAG));
+        assertFalse(DeviceConfigUtils.isNetworkStackFeatureEnabled(mContext, TEST_EXPERIMENT_FLAG));
+        assertFalse(DeviceConfigUtils.isTetheringFeatureEnabled(mContext, TEST_EXPERIMENT_FLAG));
+
+        // If the feature is force disabled, package info is not queried
+        verify(mContext, never()).getPackageManager();
+        verify(mContext, never()).getPackageName();
+        verify(mPm, never()).getPackageInfo(anyString(), anyInt());
     }
 
     @Test
@@ -271,15 +305,12 @@
         assertFalse(DeviceConfigUtils.isNetworkStackFeatureEnabled(mContext, TEST_EXPERIMENT_FLAG));
         assertFalse(DeviceConfigUtils.isTetheringFeatureEnabled(mContext, TEST_EXPERIMENT_FLAG));
 
-        // Follow defaultEnabled if the flag is not set
+        // 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));
-        assertFalse(DeviceConfigUtils.isNetworkStackFeatureEnabled(mContext, TEST_EXPERIMENT_FLAG,
-                false /* defaultEnabled */));
-        assertTrue(DeviceConfigUtils.isNetworkStackFeatureEnabled(mContext, TEST_EXPERIMENT_FLAG,
-                true /* defaultEnabled */));
+        assertFalse(DeviceConfigUtils.isNetworkStackFeatureEnabled(mContext, TEST_EXPERIMENT_FLAG));
         assertFalse(DeviceConfigUtils.isTetheringFeatureEnabled(mContext, TEST_EXPERIMENT_FLAG));
     }
 
@@ -415,25 +446,86 @@
     }
 
     @Test
-    public void testIsTetheringFeatureNotChickenedOut() throws Exception {
-        doReturn("0").when(() -> DeviceConfig.getProperty(
-                eq(NAMESPACE_TETHERING), eq(TEST_EXPERIMENT_FLAG)));
-        assertTrue(DeviceConfigUtils.isTetheringFeatureNotChickenedOut(TEST_EXPERIMENT_FLAG));
-
-        doReturn(TEST_FLAG_VALUE_STRING).when(
-                () -> DeviceConfig.getProperty(eq(NAMESPACE_TETHERING), eq(TEST_EXPERIMENT_FLAG)));
-        assertFalse(DeviceConfigUtils.isTetheringFeatureNotChickenedOut(TEST_EXPERIMENT_FLAG));
+    public void testIsFeatureNotChickenedOut() {
+        doReturn(TEST_FLAG_VALUE_STRING).when(() -> DeviceConfig.getProperty(NAMESPACE_CONNECTIVITY,
+                TEST_EXPERIMENT_FLAG));
+        doReturn(TEST_FLAG_VALUE_STRING).when(() -> DeviceConfig.getProperty(NAMESPACE_TETHERING,
+                TEST_EXPERIMENT_FLAG));
+        assertTrue(DeviceConfigUtils.isTetheringFeatureNotChickenedOut(
+                mContext, TEST_EXPERIMENT_FLAG));
+        assertTrue(DeviceConfigUtils.isNetworkStackFeatureNotChickenedOut(
+                mContext, TEST_EXPERIMENT_FLAG));
     }
 
     @Test
-    public void testIsNetworkStackFeatureNotChickenedOut() throws Exception {
-        doReturn("0").when(() -> DeviceConfig.getProperty(
-                eq(NAMESPACE_CONNECTIVITY), eq(TEST_EXPERIMENT_FLAG)));
-        assertTrue(DeviceConfigUtils.isNetworkStackFeatureNotChickenedOut(TEST_EXPERIMENT_FLAG));
+    public void testIsFeatureNotChickenedOutFeatureDefaultEnabled() throws Exception {
+        doReturn(null).when(() -> DeviceConfig.getProperty(NAMESPACE_CONNECTIVITY,
+                TEST_EXPERIMENT_FLAG));
+        doReturn(null).when(() -> DeviceConfig.getProperty(NAMESPACE_TETHERING,
+                TEST_EXPERIMENT_FLAG));
+        assertTrue(DeviceConfigUtils.isTetheringFeatureNotChickenedOut(
+                mContext, TEST_EXPERIMENT_FLAG));
+        assertTrue(DeviceConfigUtils.isNetworkStackFeatureNotChickenedOut(
+                mContext, TEST_EXPERIMENT_FLAG));
 
-        doReturn(TEST_FLAG_VALUE_STRING).when(
-                () -> DeviceConfig.getProperty(eq(NAMESPACE_CONNECTIVITY),
-                                               eq(TEST_EXPERIMENT_FLAG)));
-        assertFalse(DeviceConfigUtils.isNetworkStackFeatureNotChickenedOut(TEST_EXPERIMENT_FLAG));
+        // If the flag is unset, package info is not queried
+        verify(mContext, never()).getPackageManager();
+        verify(mContext, never()).getPackageName();
+        verify(mPm, never()).getPackageInfo(anyString(), anyInt());
+    }
+
+    @Test
+    public void testIsFeatureNotChickenedOutFeatureForceEnabled() throws Exception {
+        doReturn("1").when(() -> DeviceConfig.getProperty(NAMESPACE_CONNECTIVITY,
+                TEST_EXPERIMENT_FLAG));
+        doReturn("1").when(() -> DeviceConfig.getProperty(NAMESPACE_TETHERING,
+                TEST_EXPERIMENT_FLAG));
+        assertTrue(DeviceConfigUtils.isNetworkStackFeatureNotChickenedOut(
+                mContext, TEST_EXPERIMENT_FLAG));
+        assertTrue(DeviceConfigUtils.isTetheringFeatureNotChickenedOut(
+                mContext, TEST_EXPERIMENT_FLAG));
+
+        // If the feature is force enabled, package info is not queried
+        verify(mContext, never()).getPackageManager();
+        verify(mContext, never()).getPackageName();
+        verify(mPm, never()).getPackageInfo(anyString(), anyInt());
+    }
+
+    @Test
+    public void testIsFeatureNotChickenedOutFeatureForceDisabled() throws Exception {
+        doReturn("-1").when(() -> DeviceConfig.getProperty(NAMESPACE_CONNECTIVITY,
+                TEST_EXPERIMENT_FLAG));
+        doReturn("-1").when(() -> DeviceConfig.getProperty(NAMESPACE_TETHERING,
+                TEST_EXPERIMENT_FLAG));
+        assertFalse(DeviceConfigUtils.isNetworkStackFeatureNotChickenedOut(
+                mContext, TEST_EXPERIMENT_FLAG));
+        assertFalse(DeviceConfigUtils.isTetheringFeatureNotChickenedOut(
+                mContext, TEST_EXPERIMENT_FLAG));
+
+        // If the feature is force disabled, package info is not queried
+        verify(mContext, never()).getPackageManager();
+        verify(mContext, never()).getPackageName();
+        verify(mPm, never()).getPackageInfo(anyString(), anyInt());
+    }
+
+    @Test
+    public void testIsCoreNetworkingTrunkStableFeatureEnabled() {
+        doReturn(null).when(() -> DeviceConfig.getProperty(
+                CORE_NETWORKING_TRUNK_STABLE_NAMESPACE,
+                TEST_CORE_NETWORKING_TRUNK_STABLE_FLAG_PROPERTY));
+        assertFalse(DeviceConfigUtils.isTrunkStableFeatureEnabled(
+                TEST_TRUNK_STABLE_FLAG));
+
+        doReturn("false").when(() -> DeviceConfig.getProperty(
+                CORE_NETWORKING_TRUNK_STABLE_NAMESPACE,
+                TEST_CORE_NETWORKING_TRUNK_STABLE_FLAG_PROPERTY));
+        assertFalse(DeviceConfigUtils.isTrunkStableFeatureEnabled(
+                TEST_TRUNK_STABLE_FLAG));
+
+        doReturn("true").when(() -> DeviceConfig.getProperty(
+                CORE_NETWORKING_TRUNK_STABLE_NAMESPACE,
+                TEST_CORE_NETWORKING_TRUNK_STABLE_FLAG_PROPERTY));
+        assertTrue(DeviceConfigUtils.isTrunkStableFeatureEnabled(
+                TEST_TRUNK_STABLE_FLAG));
     }
 }
diff --git a/staticlibs/tests/unit/src/com/android/net/module/util/InetAddressUtilsTest.java b/staticlibs/tests/unit/src/com/android/net/module/util/InetAddressUtilsTest.java
index bb2b933..66427fc 100644
--- a/staticlibs/tests/unit/src/com/android/net/module/util/InetAddressUtilsTest.java
+++ b/staticlibs/tests/unit/src/com/android/net/module/util/InetAddressUtilsTest.java
@@ -18,6 +18,7 @@
 
 import static junit.framework.Assert.assertEquals;
 
+import static org.junit.Assert.assertArrayEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
 
@@ -30,6 +31,7 @@
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
+import java.net.Inet4Address;
 import java.net.Inet6Address;
 import java.net.InetAddress;
 
@@ -92,4 +94,17 @@
         assertEquals(localAddrStr + "%" + scopeId, updatedLocalAddr.getHostAddress());
         assertEquals(scopeId, updatedLocalAddr.getScopeId());
     }
+
+    @Test
+    public void testV4MappedV6Address() throws Exception {
+        final Inet4Address v4Addr = (Inet4Address) InetAddress.getByName("192.0.2.1");
+        final Inet6Address v4MappedV6Address = InetAddressUtils.v4MappedV6Address(v4Addr);
+        final byte[] expectedAddrBytes = new byte[]{
+                (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
+                (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
+                (byte) 0x00, (byte) 0x00, (byte) 0xff, (byte) 0xff,
+                (byte) 0xc0, (byte) 0x00, (byte) 0x02, (byte) 0x01,
+        };
+        assertArrayEquals(expectedAddrBytes, v4MappedV6Address.getAddress());
+    }
 }
diff --git a/staticlibs/tests/unit/src/com/android/net/module/util/StructTest.java b/staticlibs/tests/unit/src/com/android/net/module/util/StructTest.java
index b4da043..a39b7a3 100644
--- a/staticlibs/tests/unit/src/com/android/net/module/util/StructTest.java
+++ b/staticlibs/tests/unit/src/com/android/net/module/util/StructTest.java
@@ -765,6 +765,14 @@
                 msg.writeToBytes(ByteOrder.BIG_ENDIAN));
     }
 
+    @Test
+    public void testV4MappedV6Address() {
+        final IpAddressMessage msg = doParsingMessageTest("c0a86401"
+                + "00000000000000000000ffffc0a86401", IpAddressMessage.class, ByteOrder.BIG_ENDIAN);
+        assertEquals(TEST_IPV4_ADDRESS, msg.ipv4Address);
+        assertEquals(InetAddressUtils.v4MappedV6Address(TEST_IPV4_ADDRESS), msg.ipv6Address);
+    }
+
     public static class WrongIpAddressType extends Struct {
         @Field(order = 0, type = Type.Ipv4Address) public byte[] ipv4Address;
         @Field(order = 1, type = Type.Ipv6Address) public byte[] ipv6Address;
diff --git a/staticlibs/tests/unit/src/com/android/net/module/util/netlink/NetlinkUtilsTest.java b/staticlibs/tests/unit/src/com/android/net/module/util/netlink/NetlinkUtilsTest.java
index 3a72dd1..5a231fc 100644
--- a/staticlibs/tests/unit/src/com/android/net/module/util/netlink/NetlinkUtilsTest.java
+++ b/staticlibs/tests/unit/src/com/android/net/module/util/netlink/NetlinkUtilsTest.java
@@ -68,7 +68,7 @@
         final FileDescriptor fd = NetlinkUtils.netlinkSocketForProto(NETLINK_ROUTE);
         assertNotNull(fd);
 
-        NetlinkUtils.connectSocketToNetlink(fd);
+        NetlinkUtils.connectToKernel(fd);
 
         final NetlinkSocketAddress localAddr = (NetlinkSocketAddress) Os.getsockname(fd);
         assertNotNull(localAddr);
@@ -88,6 +88,11 @@
         if (SdkLevel.isAtLeastT() && targetSdk > 31) {
             var ctxt = new String(Files.readAllBytes(Paths.get("/proc/thread-self/attr/current")));
             assumeFalse("must not be platform app", ctxt.startsWith("u:r:platform_app:s0:"));
+            // NetworkStackCoverageTests uses the same UID with NetworkStack module, which
+            // still has the permission to send RTM_GETNEIGH message (sepolicy just blocks the
+            // access from untrusted_apps), also exclude the NetworkStackCoverageTests.
+            assumeFalse("network_stack context is expected to have permission to send RTM_GETNEIGH",
+                    ctxt.startsWith("u:r:network_stack:s0"));
             try {
                 NetlinkUtils.sendMessage(fd, req, 0, req.length, TEST_TIMEOUT_MS);
                 fail("RTM_GETNEIGH is not allowed for apps targeting SDK > 31 on T+ platforms,"
@@ -148,7 +153,7 @@
         final FileDescriptor fd = NetlinkUtils.netlinkSocketForProto(NETLINK_ROUTE);
         assertNotNull(fd);
 
-        NetlinkUtils.connectSocketToNetlink(fd);
+        NetlinkUtils.connectToKernel(fd);
 
         final NetlinkSocketAddress localAddr = (NetlinkSocketAddress) Os.getsockname(fd);
         assertNotNull(localAddr);
diff --git a/staticlibs/tests/unit/src/com/android/testutils/HandlerUtilsTest.kt b/staticlibs/tests/unit/src/com/android/testutils/HandlerUtilsTest.kt
index 0f6fa48..440b836 100644
--- a/staticlibs/tests/unit/src/com/android/testutils/HandlerUtilsTest.kt
+++ b/staticlibs/tests/unit/src/com/android/testutils/HandlerUtilsTest.kt
@@ -27,7 +27,7 @@
 import org.junit.runners.JUnit4
 
 private const val ATTEMPTS = 50 // Causes testWaitForIdle to take about 150ms on aosp_crosshatch-eng
-private const val TIMEOUT_MS = 200
+private const val TIMEOUT_MS = 1000
 
 @RunWith(JUnit4::class)
 class HandlerUtilsTest {
diff --git a/staticlibs/testutils/Android.bp b/staticlibs/testutils/Android.bp
index 5fe7ac3..a5e5afb 100644
--- a/staticlibs/testutils/Android.bp
+++ b/staticlibs/testutils/Android.bp
@@ -38,6 +38,7 @@
         "net-utils-device-common",
         "net-utils-device-common-async",
         "net-utils-device-common-netlink",
+        "net-utils-device-common-struct",
         "net-utils-device-common-wear",
         "modules-utils-build_system",
     ],
diff --git a/staticlibs/testutils/app/connectivitychecker/Android.bp b/staticlibs/testutils/app/connectivitychecker/Android.bp
index f7118cf..049ec9e 100644
--- a/staticlibs/testutils/app/connectivitychecker/Android.bp
+++ b/staticlibs/testutils/app/connectivitychecker/Android.bp
@@ -20,9 +20,9 @@
     name: "ConnectivityTestPreparer",
     srcs: ["src/**/*.kt"],
     sdk_version: "system_current",
-    // Allow running the test on any device with SDK Q+, even when built from a branch that uses
+    // Allow running the test on any device with SDK R+, even when built from a branch that uses
     // an unstable SDK, by targeting a stable SDK regardless of the build SDK.
-    min_sdk_version: "29",
+    min_sdk_version: "30",
     target_sdk_version: "30",
     static_libs: [
         "androidx.test.rules",
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/DevSdkIgnoreRule.kt b/staticlibs/testutils/devicetests/com/android/testutils/DevSdkIgnoreRule.kt
index 35f22b9..46229b0 100644
--- a/staticlibs/testutils/devicetests/com/android/testutils/DevSdkIgnoreRule.kt
+++ b/staticlibs/testutils/devicetests/com/android/testutils/DevSdkIgnoreRule.kt
@@ -27,6 +27,9 @@
 
 @Deprecated("Use Build.VERSION_CODES", ReplaceWith("Build.VERSION_CODES.S_V2"))
 const val SC_V2 = Build.VERSION_CODES.S_V2
+// TODO: Remove this when Build.VERSION_CODES.VANILLA_ICE_CREAM is available in all branches
+// where this code builds
+const val VANILLA_ICE_CREAM = 35 // Bui1ld.VERSION_CODES.VANILLA_ICE_CREAM
 
 private val MAX_TARGET_SDK_ANNOTATION_RE = Pattern.compile("MaxTargetSdk([0-9]+)$")
 private val targetSdk = InstrumentationRegistry.getContext().applicationInfo.targetSdkVersion
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/DevSdkIgnoreRunner.kt b/staticlibs/testutils/devicetests/com/android/testutils/DevSdkIgnoreRunner.kt
index 2e73666..2d281fd 100644
--- a/staticlibs/testutils/devicetests/com/android/testutils/DevSdkIgnoreRunner.kt
+++ b/staticlibs/testutils/devicetests/com/android/testutils/DevSdkIgnoreRunner.kt
@@ -19,6 +19,7 @@
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import com.android.testutils.DevSdkIgnoreRule.IgnoreAfter
 import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo
+import java.lang.reflect.Modifier
 import org.junit.runner.Description
 import org.junit.runner.Runner
 import org.junit.runner.manipulation.Filter
@@ -27,7 +28,7 @@
 import org.junit.runner.manipulation.Sortable
 import org.junit.runner.manipulation.Sorter
 import org.junit.runner.notification.RunNotifier
-import kotlin.jvm.Throws
+import org.junit.runners.Parameterized
 
 /**
  * A runner that can skip tests based on the development SDK as defined in [DevSdkIgnoreRule].
@@ -41,6 +42,9 @@
  * the whole class if they do not match the development SDK as defined in [DevSdkIgnoreRule].
  * Otherwise, it will delegate to [AndroidJUnit4] to run the test as usual.
  *
+ * This class automatically uses the Parameterized runner as its base runner when needed, so the
+ * @Parameterized.Parameters annotation and its friends can be used in tests using this runner.
+ *
  * Example usage:
  *
  *     @RunWith(DevSdkIgnoreRunner::class)
@@ -48,13 +52,34 @@
  *     class MyTestClass { ... }
  */
 class DevSdkIgnoreRunner(private val klass: Class<*>) : Runner(), Filterable, Sortable {
-    private val baseRunner = klass.let {
+    // Inference correctly infers Runner & Filterable & Sortable for |baseRunner|, but the
+    // Java bytecode doesn't have a way to express this. Give this type a name by wrapping it.
+    private class RunnerWrapper<T>(private val wrapped: T) :
+            Runner(), Filterable by wrapped, Sortable by wrapped
+            where T : Runner, T : Filterable, T : Sortable {
+        override fun getDescription(): Description = wrapped.description
+        override fun run(notifier: RunNotifier?) = wrapped.run(notifier)
+    }
+
+    private val baseRunner: RunnerWrapper<*>? = klass.let {
         val ignoreAfter = it.getAnnotation(IgnoreAfter::class.java)
         val ignoreUpTo = it.getAnnotation(IgnoreUpTo::class.java)
 
-        if (isDevSdkInRange(ignoreUpTo, ignoreAfter)) AndroidJUnit4(klass) else null
+        if (!isDevSdkInRange(ignoreUpTo, ignoreAfter)) {
+            null
+        } else if (it.hasParameterizedMethod()) {
+            // Parameterized throws if there is no static method annotated with @Parameters, which
+            // isn't too useful. Use it if there are, otherwise use its base AndroidJUnit4 runner.
+            RunnerWrapper(Parameterized(klass))
+        } else {
+            RunnerWrapper(AndroidJUnit4(klass))
+        }
     }
 
+    private fun <T> Class<T>.hasParameterizedMethod(): Boolean = methods.any {
+        Modifier.isStatic(it.modifiers) &&
+                it.isAnnotationPresent(Parameterized.Parameters::class.java) }
+
     override fun run(notifier: RunNotifier) {
         if (baseRunner != null) {
             baseRunner.run(notifier)
@@ -88,4 +113,4 @@
     override fun sort(sorter: Sorter?) {
         baseRunner?.sort(sorter)
     }
-}
\ No newline at end of file
+}
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/TestBpfMap.java b/staticlibs/testutils/devicetests/com/android/testutils/TestBpfMap.java
index 733bd98..70f20d6 100644
--- a/staticlibs/testutils/devicetests/com/android/testutils/TestBpfMap.java
+++ b/staticlibs/testutils/devicetests/com/android/testutils/TestBpfMap.java
@@ -43,6 +43,8 @@
 public class TestBpfMap<K extends Struct, V extends Struct> implements IBpfMap<K, V> {
     private final ConcurrentHashMap<K, V> mMap = new ConcurrentHashMap<>();
 
+    public TestBpfMap() {}
+
     // TODO: Remove this constructor
     public TestBpfMap(final Class<K> key, final Class<V> value) {
     }
diff --git a/staticlibs/testutils/host/com/android/testutils/ConnectivityTestTargetPreparer.kt b/staticlibs/testutils/host/com/android/testutils/ConnectivityTestTargetPreparer.kt
index 3fc74aa..600a623 100644
--- a/staticlibs/testutils/host/com/android/testutils/ConnectivityTestTargetPreparer.kt
+++ b/staticlibs/testutils/host/com/android/testutils/ConnectivityTestTargetPreparer.kt
@@ -32,6 +32,10 @@
 private const val CONNECTIVITY_CHECK_RUNNER_NAME = "androidx.test.runner.AndroidJUnitRunner"
 private const val IGNORE_CONN_CHECK_OPTION = "ignore-connectivity-check"
 
+// The default updater package names, which might be updating packages while the CTS
+// are running
+private val UPDATER_PKGS = arrayOf("com.google.android.gms", "com.android.vending")
+
 /**
  * A target preparer that sets up and verifies a device for connectivity tests.
  *
@@ -45,35 +49,42 @@
     @Option(name = IGNORE_CONN_CHECK_OPTION,
             description = "Disables the check for mobile data and wifi")
     private var ignoreConnectivityCheck = false
+    // The default value is never used, but false is a reasonable default
+    private var originalTestChainEnabled = false
+    private val originalUpdaterPkgsStatus = HashMap<String, Boolean>()
 
-    override fun setUp(testInformation: TestInformation) {
+    override fun setUp(testInfo: TestInformation) {
         if (isDisabled) return
-        disableGmsUpdate(testInformation)
-        runPreparerApk(testInformation)
+        disableGmsUpdate(testInfo)
+        originalTestChainEnabled = getTestChainEnabled(testInfo)
+        originalUpdaterPkgsStatus.putAll(getUpdaterPkgsStatus(testInfo))
+        setUpdaterNetworkingEnabled(testInfo, enableChain = true,
+                enablePkgs = UPDATER_PKGS.associateWith { false })
+        runPreparerApk(testInfo)
     }
 
-    private fun runPreparerApk(testInformation: TestInformation) {
+    private fun runPreparerApk(testInfo: TestInformation) {
         installer.setCleanApk(true)
         installer.addTestFileName(CONNECTIVITY_CHECKER_APK)
         installer.setShouldGrantPermission(true)
-        installer.setUp(testInformation)
+        installer.setUp(testInfo)
 
         val runner = DefaultRemoteAndroidTestRunner(
                 CONNECTIVITY_PKG_NAME,
                 CONNECTIVITY_CHECK_RUNNER_NAME,
-                testInformation.device.iDevice)
+                testInfo.device.iDevice)
         runner.runOptions = "--no-hidden-api-checks"
 
         val receiver = CollectingTestListener()
-        if (!testInformation.device.runInstrumentationTests(runner, receiver)) {
+        if (!testInfo.device.runInstrumentationTests(runner, receiver)) {
             throw TargetSetupError("Device state check failed to complete",
-                    testInformation.device.deviceDescriptor)
+                    testInfo.device.deviceDescriptor)
         }
 
         val runResult = receiver.currentRunResults
         if (runResult.isRunFailure) {
             throw TargetSetupError("Failed to check device state before the test: " +
-                    runResult.runFailureMessage, testInformation.device.deviceDescriptor)
+                    runResult.runFailureMessage, testInfo.device.deviceDescriptor)
         }
 
         val ignoredTestClasses = mutableSetOf<String>()
@@ -92,25 +103,50 @@
         if (errorMsg.isBlank()) return
 
         throw TargetSetupError("Device setup checks failed. Check the test bench: \n$errorMsg",
-                testInformation.device.deviceDescriptor)
+                testInfo.device.deviceDescriptor)
     }
 
-    private fun disableGmsUpdate(testInformation: TestInformation) {
+    private fun disableGmsUpdate(testInfo: TestInformation) {
         // This will be a no-op on devices without root (su) or not using gservices, but that's OK.
-        testInformation.device.executeShellCommand("su 0 am broadcast " +
+        testInfo.exec("su 0 am broadcast " +
                 "-a com.google.gservices.intent.action.GSERVICES_OVERRIDE " +
                 "-e finsky.play_services_auto_update_enabled false")
     }
 
-    private fun clearGmsUpdateOverride(testInformation: TestInformation) {
-        testInformation.device.executeShellCommand("su 0 am broadcast " +
+    private fun clearGmsUpdateOverride(testInfo: TestInformation) {
+        testInfo.exec("su 0 am broadcast " +
                 "-a com.google.gservices.intent.action.GSERVICES_OVERRIDE " +
                 "--esn finsky.play_services_auto_update_enabled")
     }
 
-    override fun tearDown(testInformation: TestInformation, e: Throwable?) {
+    private fun setUpdaterNetworkingEnabled(
+            testInfo: TestInformation,
+            enableChain: Boolean,
+            enablePkgs: Map<String, Boolean>
+    ) {
+        // Build.VERSION_CODES.S = 31 where this is not available, then do nothing.
+        if (testInfo.device.getApiLevel() < 31) return
+        testInfo.exec("cmd connectivity set-chain3-enabled $enableChain")
+        enablePkgs.forEach { (pkg, allow) ->
+            testInfo.exec("cmd connectivity set-package-networking-enabled $allow $pkg")
+        }
+    }
+
+    private fun getTestChainEnabled(testInfo: TestInformation) =
+            testInfo.exec("cmd connectivity get-chain3-enabled").contains("chain:enabled")
+
+    private fun getUpdaterPkgsStatus(testInfo: TestInformation) =
+            UPDATER_PKGS.associateWith { pkg ->
+                !testInfo.exec("cmd connectivity get-package-networking-enabled $pkg")
+                        .contains(":deny")
+            }
+
+    override fun tearDown(testInfo: TestInformation, e: Throwable?) {
         if (isTearDownDisabled) return
-        installer.tearDown(testInformation, e)
-        clearGmsUpdateOverride(testInformation)
+        installer.tearDown(testInfo, e)
+        setUpdaterNetworkingEnabled(testInfo,
+                enableChain = originalTestChainEnabled,
+                enablePkgs = originalUpdaterPkgsStatus)
+        clearGmsUpdateOverride(testInfo)
     }
 }
diff --git a/staticlibs/testutils/host/com/android/testutils/DisableConfigSyncTargetPreparer.kt b/staticlibs/testutils/host/com/android/testutils/DisableConfigSyncTargetPreparer.kt
index 63f05a6..bc00f3c 100644
--- a/staticlibs/testutils/host/com/android/testutils/DisableConfigSyncTargetPreparer.kt
+++ b/staticlibs/testutils/host/com/android/testutils/DisableConfigSyncTargetPreparer.kt
@@ -58,4 +58,4 @@
     }
 }
 
-private fun TestInformation.exec(cmd: String) = this.device.executeShellCommand(cmd)
\ No newline at end of file
+fun TestInformation.exec(cmd: String) = this.device.executeShellCommand(cmd)
diff --git a/tests/benchmark/Android.bp b/tests/benchmark/Android.bp
index 77383ad..6ea5347 100644
--- a/tests/benchmark/Android.bp
+++ b/tests/benchmark/Android.bp
@@ -29,6 +29,7 @@
         "src/**/*.kt",
         "src/**/*.aidl",
     ],
+    asset_dirs: ["assets"],
     static_libs: [
         "androidx.test.rules",
         "mockito-target-minus-junit4",
diff --git a/tests/benchmark/assets/dataset/A052701.zip b/tests/benchmark/assets/dataset/A052701.zip
new file mode 100644
index 0000000..fdde1ad
--- /dev/null
+++ b/tests/benchmark/assets/dataset/A052701.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/A052801.zip b/tests/benchmark/assets/dataset/A052801.zip
new file mode 100644
index 0000000..7f908b7
--- /dev/null
+++ b/tests/benchmark/assets/dataset/A052801.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/A052802.zip b/tests/benchmark/assets/dataset/A052802.zip
new file mode 100644
index 0000000..180ad3e
--- /dev/null
+++ b/tests/benchmark/assets/dataset/A052802.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/A052803.zip b/tests/benchmark/assets/dataset/A052803.zip
new file mode 100644
index 0000000..321a79b
--- /dev/null
+++ b/tests/benchmark/assets/dataset/A052803.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/A052804.zip b/tests/benchmark/assets/dataset/A052804.zip
new file mode 100644
index 0000000..298ec04
--- /dev/null
+++ b/tests/benchmark/assets/dataset/A052804.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/A052901.zip b/tests/benchmark/assets/dataset/A052901.zip
new file mode 100644
index 0000000..0f49543
--- /dev/null
+++ b/tests/benchmark/assets/dataset/A052901.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/A052902.zip b/tests/benchmark/assets/dataset/A052902.zip
new file mode 100644
index 0000000..ec22456
--- /dev/null
+++ b/tests/benchmark/assets/dataset/A052902.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/A053001.zip b/tests/benchmark/assets/dataset/A053001.zip
new file mode 100644
index 0000000..ad5d82e
--- /dev/null
+++ b/tests/benchmark/assets/dataset/A053001.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/A053002.zip b/tests/benchmark/assets/dataset/A053002.zip
new file mode 100644
index 0000000..8a4bb0c
--- /dev/null
+++ b/tests/benchmark/assets/dataset/A053002.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/A053003.zip b/tests/benchmark/assets/dataset/A053003.zip
new file mode 100644
index 0000000..24d2057
--- /dev/null
+++ b/tests/benchmark/assets/dataset/A053003.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/A053004.zip b/tests/benchmark/assets/dataset/A053004.zip
new file mode 100644
index 0000000..352f93f
--- /dev/null
+++ b/tests/benchmark/assets/dataset/A053004.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/A053005.zip b/tests/benchmark/assets/dataset/A053005.zip
new file mode 100644
index 0000000..2b49a1b
--- /dev/null
+++ b/tests/benchmark/assets/dataset/A053005.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/A053006.zip b/tests/benchmark/assets/dataset/A053006.zip
new file mode 100644
index 0000000..a59f2ec
--- /dev/null
+++ b/tests/benchmark/assets/dataset/A053006.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/A053007.zip b/tests/benchmark/assets/dataset/A053007.zip
new file mode 100644
index 0000000..df7ae74
--- /dev/null
+++ b/tests/benchmark/assets/dataset/A053007.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/A053101.zip b/tests/benchmark/assets/dataset/A053101.zip
new file mode 100644
index 0000000..c10ed64
--- /dev/null
+++ b/tests/benchmark/assets/dataset/A053101.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/A053102.zip b/tests/benchmark/assets/dataset/A053102.zip
new file mode 100644
index 0000000..8c9f9cf
--- /dev/null
+++ b/tests/benchmark/assets/dataset/A053102.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/A053103.zip b/tests/benchmark/assets/dataset/A053103.zip
new file mode 100644
index 0000000..9202c50
--- /dev/null
+++ b/tests/benchmark/assets/dataset/A053103.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/A053104.zip b/tests/benchmark/assets/dataset/A053104.zip
new file mode 100644
index 0000000..3c77724
--- /dev/null
+++ b/tests/benchmark/assets/dataset/A053104.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/A060101.zip b/tests/benchmark/assets/dataset/A060101.zip
new file mode 100644
index 0000000..86443a7
--- /dev/null
+++ b/tests/benchmark/assets/dataset/A060101.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/A060102.zip b/tests/benchmark/assets/dataset/A060102.zip
new file mode 100644
index 0000000..4f2cf49
--- /dev/null
+++ b/tests/benchmark/assets/dataset/A060102.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/A060201.zip b/tests/benchmark/assets/dataset/A060201.zip
new file mode 100644
index 0000000..3c28bec
--- /dev/null
+++ b/tests/benchmark/assets/dataset/A060201.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/A060202.zip b/tests/benchmark/assets/dataset/A060202.zip
new file mode 100644
index 0000000..e39e493
--- /dev/null
+++ b/tests/benchmark/assets/dataset/A060202.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/B053001.zip b/tests/benchmark/assets/dataset/B053001.zip
new file mode 100644
index 0000000..8408744
--- /dev/null
+++ b/tests/benchmark/assets/dataset/B053001.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/B053002.zip b/tests/benchmark/assets/dataset/B053002.zip
new file mode 100644
index 0000000..5245f70
--- /dev/null
+++ b/tests/benchmark/assets/dataset/B053002.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/B060101.zip b/tests/benchmark/assets/dataset/B060101.zip
new file mode 100644
index 0000000..242c0d1
--- /dev/null
+++ b/tests/benchmark/assets/dataset/B060101.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/B060201.zip b/tests/benchmark/assets/dataset/B060201.zip
new file mode 100644
index 0000000..29df25a
--- /dev/null
+++ b/tests/benchmark/assets/dataset/B060201.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/B060202.zip b/tests/benchmark/assets/dataset/B060202.zip
new file mode 100644
index 0000000..bda9edd
--- /dev/null
+++ b/tests/benchmark/assets/dataset/B060202.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/B060203.zip b/tests/benchmark/assets/dataset/B060203.zip
new file mode 100644
index 0000000..b9fccfe
--- /dev/null
+++ b/tests/benchmark/assets/dataset/B060203.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/B060204.zip b/tests/benchmark/assets/dataset/B060204.zip
new file mode 100644
index 0000000..66227d2
--- /dev/null
+++ b/tests/benchmark/assets/dataset/B060204.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/B060205.zip b/tests/benchmark/assets/dataset/B060205.zip
new file mode 100644
index 0000000..6aaa06b
--- /dev/null
+++ b/tests/benchmark/assets/dataset/B060205.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/B060206.zip b/tests/benchmark/assets/dataset/B060206.zip
new file mode 100644
index 0000000..18445b0
--- /dev/null
+++ b/tests/benchmark/assets/dataset/B060206.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/B060207.zip b/tests/benchmark/assets/dataset/B060207.zip
new file mode 100644
index 0000000..20f7c5b
--- /dev/null
+++ b/tests/benchmark/assets/dataset/B060207.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/C060101.zip b/tests/benchmark/assets/dataset/C060101.zip
new file mode 100644
index 0000000..0b1c29f
--- /dev/null
+++ b/tests/benchmark/assets/dataset/C060101.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/C060102.zip b/tests/benchmark/assets/dataset/C060102.zip
new file mode 100644
index 0000000..8064905
--- /dev/null
+++ b/tests/benchmark/assets/dataset/C060102.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/C060103.zip b/tests/benchmark/assets/dataset/C060103.zip
new file mode 100644
index 0000000..d0e819f
--- /dev/null
+++ b/tests/benchmark/assets/dataset/C060103.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/C060104.zip b/tests/benchmark/assets/dataset/C060104.zip
new file mode 100644
index 0000000..f87ca8d
--- /dev/null
+++ b/tests/benchmark/assets/dataset/C060104.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/C060105.zip b/tests/benchmark/assets/dataset/C060105.zip
new file mode 100644
index 0000000..e869895
--- /dev/null
+++ b/tests/benchmark/assets/dataset/C060105.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/C060106.zip b/tests/benchmark/assets/dataset/C060106.zip
new file mode 100644
index 0000000..6d25a98
--- /dev/null
+++ b/tests/benchmark/assets/dataset/C060106.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/C060107.zip b/tests/benchmark/assets/dataset/C060107.zip
new file mode 100644
index 0000000..a7cb31c
--- /dev/null
+++ b/tests/benchmark/assets/dataset/C060107.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/C060108.zip b/tests/benchmark/assets/dataset/C060108.zip
new file mode 100644
index 0000000..c1a5898
--- /dev/null
+++ b/tests/benchmark/assets/dataset/C060108.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/C060109.zip b/tests/benchmark/assets/dataset/C060109.zip
new file mode 100644
index 0000000..bb9116e
--- /dev/null
+++ b/tests/benchmark/assets/dataset/C060109.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/C060110.zip b/tests/benchmark/assets/dataset/C060110.zip
new file mode 100644
index 0000000..5ca0f96
--- /dev/null
+++ b/tests/benchmark/assets/dataset/C060110.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/C060111.zip b/tests/benchmark/assets/dataset/C060111.zip
new file mode 100644
index 0000000..6a12d7e
--- /dev/null
+++ b/tests/benchmark/assets/dataset/C060111.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/C060112.zip b/tests/benchmark/assets/dataset/C060112.zip
new file mode 100644
index 0000000..fa2c30b
--- /dev/null
+++ b/tests/benchmark/assets/dataset/C060112.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/C060113.zip b/tests/benchmark/assets/dataset/C060113.zip
new file mode 100644
index 0000000..63a34ba
--- /dev/null
+++ b/tests/benchmark/assets/dataset/C060113.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/C060114.zip b/tests/benchmark/assets/dataset/C060114.zip
new file mode 100644
index 0000000..bd60927
--- /dev/null
+++ b/tests/benchmark/assets/dataset/C060114.zip
Binary files differ
diff --git a/tests/benchmark/res/raw/netstats-many-uids-zip b/tests/benchmark/assets/dataset/netstats-many-uids.zip
similarity index 98%
rename from tests/benchmark/res/raw/netstats-many-uids-zip
rename to tests/benchmark/assets/dataset/netstats-many-uids.zip
index 22e8254..9554aaa 100644
--- a/tests/benchmark/res/raw/netstats-many-uids-zip
+++ b/tests/benchmark/assets/dataset/netstats-many-uids.zip
Binary files differ
diff --git a/tests/benchmark/src/android/net/netstats/benchmarktests/NetworkStatsTest.kt b/tests/benchmark/src/android/net/netstats/benchmarktests/NetworkStatsTest.kt
index e80548b..585157f 100644
--- a/tests/benchmark/src/android/net/netstats/benchmarktests/NetworkStatsTest.kt
+++ b/tests/benchmark/src/android/net/netstats/benchmarktests/NetworkStatsTest.kt
@@ -20,10 +20,9 @@
 import android.net.NetworkStatsCollection
 import android.net.netstats.NetworkStatsDataMigrationUtils.PREFIX_UID
 import android.os.DropBoxManager
-import androidx.test.InstrumentationRegistry
+import androidx.test.platform.app.InstrumentationRegistry
 import com.android.internal.util.FileRotator
 import com.android.internal.util.FileRotator.Reader
-import com.android.server.connectivity.benchmarktests.R
 import com.android.server.net.NetworkStatsRecorder
 import java.io.BufferedInputStream
 import java.io.DataInputStream
@@ -44,23 +43,22 @@
     companion object {
         private val DEFAULT_BUFFER_SIZE = 8192
         private val FILE_CACHE_WARM_UP_REPEAT_COUNT = 10
-        private val TEST_REPEAT_COUNT = 10
         private val UID_COLLECTION_BUCKET_DURATION_MS = TimeUnit.HOURS.toMillis(2)
         private val UID_RECORDER_ROTATE_AGE_MS = TimeUnit.DAYS.toMillis(15)
         private val UID_RECORDER_DELETE_AGE_MS = TimeUnit.DAYS.toMillis(90)
+        private val TEST_DATASET_SUBFOLDER = "dataset/"
 
-        private val testFilesDir by lazy {
-            // These file generated by using real user dataset which has many uid records
-            // and agreed to share the dataset for testing purpose. These dataset can be
-            // extracted from rooted devices by using
-            // "adb pull /data/misc/apexdata/com.android.tethering/netstats" command.
-            val zipInputStream =
-                ZipInputStream(getInputStreamForResource(R.raw.netstats_many_uids_zip))
-            unzipToTempDir(zipInputStream)
-        }
-
-        private val uidTestFiles: List<File> by lazy {
-            getSortedListForPrefix(testFilesDir, "uid")
+        // These files are generated by using real user dataset which has many uid records
+        // and agreed to share the dataset for testing purpose. These dataset can be
+        // extracted from rooted devices by using
+        // "adb pull /data/misc/apexdata/com.android.tethering/netstats" command.
+        private val testFilesAssets by lazy {
+            val zipFiles = context.assets.list(TEST_DATASET_SUBFOLDER)!!.asList()
+            zipFiles.map {
+                val zipInputStream =
+                    ZipInputStream((TEST_DATASET_SUBFOLDER + it).toAssetInputStream())
+                File(unzipToTempDir(zipInputStream), "netstats")
+            }
         }
 
         // Test results shows the test cases who read the file first will take longer time to
@@ -72,24 +70,34 @@
         @BeforeClass
         fun setUpOnce() {
             repeat(FILE_CACHE_WARM_UP_REPEAT_COUNT) {
-                val collection = NetworkStatsCollection(UID_COLLECTION_BUCKET_DURATION_MS)
-                for (file in uidTestFiles) {
-                    readFile(file, collection)
+                testFilesAssets.forEach {
+                    val uidTestFiles = getSortedListForPrefix(it, "uid")
+                    val collection = NetworkStatsCollection(UID_COLLECTION_BUCKET_DURATION_MS)
+                    for (file in uidTestFiles) {
+                        readFile(file, collection)
+                    }
                 }
             }
         }
 
-        private fun getInputStreamForResource(resourceId: Int): DataInputStream =
-            DataInputStream(
-                InstrumentationRegistry.getContext()
-                    .getResources().openRawResource(resourceId)
-            )
+        val context get() = InstrumentationRegistry.getInstrumentation().getContext()
+        private fun String.toAssetInputStream() = DataInputStream(context.assets.open(this))
 
         private fun unzipToTempDir(zis: ZipInputStream): File {
             val statsDir =
                 Files.createTempDirectory(NetworkStatsTest::class.simpleName).toFile()
             generateSequence { zis.nextEntry }.forEach { entry ->
-                FileOutputStream(File(statsDir, entry.name)).use {
+                val entryFile = File(statsDir, entry.name)
+                if (entry.isDirectory) {
+                    entryFile.mkdirs()
+                    return@forEach
+                }
+
+                // Make sure all folders exists. There is no guarantee anywhere.
+                entryFile.parentFile!!.mkdirs()
+
+                // If the entry is a file extract it.
+                FileOutputStream(entryFile).use {
                     zis.copyTo(it, DEFAULT_BUFFER_SIZE)
                 }
             }
@@ -99,7 +107,7 @@
         // List [xt|uid|uid_tag].<start>-<end> files under the given directory.
         private fun getSortedListForPrefix(statsDir: File, prefix: String): List<File> {
             assertTrue(statsDir.exists())
-            return statsDir.list() { dir, name -> name.startsWith("$prefix.") }
+            return statsDir.list { _, name -> name.startsWith("$prefix.") }
                 .orEmpty()
                 .map { it -> File(statsDir, it) }
                 .sorted()
@@ -115,7 +123,8 @@
     fun testReadCollection_manyUids() {
         // The file cache is warmed up by the @BeforeClass method, so now the test can repeat
         // this a number of time to have a stable number.
-        repeat(TEST_REPEAT_COUNT) {
+        testFilesAssets.forEach {
+            val uidTestFiles = getSortedListForPrefix(it, "uid")
             val collection = NetworkStatsCollection(UID_COLLECTION_BUCKET_DURATION_MS)
             for (file in uidTestFiles) {
                 readFile(file, collection)
@@ -127,10 +136,10 @@
     fun testReadFromRecorder_manyUids() {
         val mockObserver = mock<NonMonotonicObserver<String>>()
         val mockDropBox = mock<DropBoxManager>()
-        repeat(TEST_REPEAT_COUNT) {
+        testFilesAssets.forEach {
             val recorder = NetworkStatsRecorder(
                 FileRotator(
-                    testFilesDir, PREFIX_UID, UID_RECORDER_ROTATE_AGE_MS, UID_RECORDER_DELETE_AGE_MS
+                    it, PREFIX_UID, UID_RECORDER_ROTATE_AGE_MS, UID_RECORDER_DELETE_AGE_MS
                 ),
                 mockObserver,
                 mockDropBox,
diff --git a/tests/common/java/android/net/NetworkCapabilitiesTest.java b/tests/common/java/android/net/NetworkCapabilitiesTest.java
index aae3425..bec9a4a 100644
--- a/tests/common/java/android/net/NetworkCapabilitiesTest.java
+++ b/tests/common/java/android/net/NetworkCapabilitiesTest.java
@@ -26,6 +26,7 @@
 import static android.net.NetworkCapabilities.NET_CAPABILITY_ENTERPRISE;
 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_MMS;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_NOT_METERED;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED;
@@ -63,6 +64,7 @@
 import static com.android.modules.utils.build.SdkLevel.isAtLeastR;
 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;
 import static com.android.testutils.DevSdkIgnoreRuleKt.SC_V2;
 import static com.android.testutils.MiscAsserts.assertEmpty;
 import static com.android.testutils.MiscAsserts.assertThrows;
@@ -369,6 +371,9 @@
             .addCapability(NET_CAPABILITY_INTERNET)
             .addCapability(NET_CAPABILITY_EIMS)
             .addCapability(NET_CAPABILITY_NOT_METERED);
+        if (isAtLeastV()) {
+            netCap.addCapability(NET_CAPABILITY_LOCAL_NETWORK);
+        }
         if (isAtLeastS()) {
             final ArraySet<Integer> allowedUids = new ArraySet<>();
             allowedUids.add(4);
diff --git a/tests/cts/hostside/AndroidTest.xml b/tests/cts/hostside/AndroidTest.xml
index e83e36a..90b7875 100644
--- a/tests/cts/hostside/AndroidTest.xml
+++ b/tests/cts/hostside/AndroidTest.xml
@@ -33,6 +33,11 @@
         <option name="teardown-command" value="cmd netpolicy stop-watching" />
     </target_preparer>
 
+    <target_preparer class="com.android.tradefed.targetprep.DeviceSetup">
+        <option name="force-skip-system-props" value="true" />
+        <option name="set-global-setting" key="verifier_verify_adb_installs" value="0" />
+    </target_preparer>
+
     <test class="com.android.compatibility.common.tradefed.testtype.JarHostTest" >
         <option name="jar" value="CtsHostsideNetworkTests.jar" />
         <option name="runtime-hint" value="3m56s" />
diff --git a/tests/cts/hostside/app/Android.bp b/tests/cts/hostside/app/Android.bp
index 2245382..470bb17 100644
--- a/tests/cts/hostside/app/Android.bp
+++ b/tests/cts/hostside/app/Android.bp
@@ -38,7 +38,6 @@
     srcs: ["src/**/*.java"],
     // Tag this module as a cts test artifact
     test_suites: [
-        "cts",
         "general-tests",
         "sts",
     ],
diff --git a/tests/cts/hostside/app/src/com/android/cts/net/hostside/AbstractRestrictBackgroundNetworkTestCase.java b/tests/cts/hostside/app/src/com/android/cts/net/hostside/AbstractRestrictBackgroundNetworkTestCase.java
index 89a55a7..bb32052 100644
--- a/tests/cts/hostside/app/src/com/android/cts/net/hostside/AbstractRestrictBackgroundNetworkTestCase.java
+++ b/tests/cts/hostside/app/src/com/android/cts/net/hostside/AbstractRestrictBackgroundNetworkTestCase.java
@@ -37,6 +37,7 @@
 import static org.junit.Assert.assertTrue;
 import static org.junit.Assert.fail;
 
+import android.annotation.NonNull;
 import android.app.ActivityManager;
 import android.app.Instrumentation;
 import android.app.NotificationManager;
@@ -61,6 +62,7 @@
 import android.util.Log;
 import android.util.Pair;
 
+import com.android.compatibility.common.util.AmUtils;
 import com.android.compatibility.common.util.BatteryUtils;
 import com.android.compatibility.common.util.DeviceConfigStateHelper;
 
@@ -198,7 +200,8 @@
     protected void tearDown() throws Exception {
         executeShellCommand("cmd netpolicy stop-watching");
         mServiceClient.unbind();
-        if (mLock.isHeld()) mLock.release();
+        final PowerManager.WakeLock lock = mLock;
+        if (null != lock && lock.isHeld()) lock.release();
     }
 
     protected int getUid(String packageName) throws Exception {
@@ -436,6 +439,42 @@
     }
 
     /**
+     * Asserts whether the network is blocked by accessing bpf maps if command-line tool supports.
+     */
+    void assertNetworkAccessBlockedByBpf(boolean expectBlocked, int uid, boolean metered) {
+        final String result;
+        try {
+            result = executeShellCommand(
+                    "cmd network_stack is-uid-networking-blocked " + uid + " " + metered);
+        } catch (AssertionError e) {
+            // If NetworkStack is too old to support this command, ignore and continue
+            // this test to verify other parts.
+            if (e.getMessage().contains("No shell command implementation.")) {
+                return;
+            }
+            throw e;
+        }
+
+        // Tethering module is too old.
+        if (result.contains("API is unsupported")) {
+            return;
+        }
+
+        assertEquals(expectBlocked, parseBooleanOrThrow(result.trim()));
+    }
+
+    /**
+     * Similar to {@link Boolean#parseBoolean} but throws when the input
+     * is unexpected instead of returning false.
+     */
+    private static boolean parseBooleanOrThrow(@NonNull String s) {
+        // Don't use Boolean.parseBoolean
+        if ("true".equalsIgnoreCase(s)) return true;
+        if ("false".equalsIgnoreCase(s)) return false;
+        throw new IllegalArgumentException("Unexpected: " + s);
+    }
+
+    /**
      * Checks whether the network is available as expected.
      *
      * @return error message with the mismatch (or empty if assertion passed).
@@ -719,10 +758,12 @@
         Log.i(TAG, "Setting Battery Saver Mode to " + enabled);
         if (enabled) {
             turnBatteryOn();
+            AmUtils.waitForBroadcastBarrier();
             executeSilentShellCommand("cmd power set-mode 1");
         } else {
             executeSilentShellCommand("cmd power set-mode 0");
             turnBatteryOff();
+            AmUtils.waitForBroadcastBarrier();
         }
     }
 
@@ -748,27 +789,24 @@
         assertDelayedShellCommand("dumpsys deviceidle get deep", enabled ? "IDLE" : "ACTIVE");
     }
 
-    protected void setAppIdle(boolean enabled) throws Exception {
+    protected void setAppIdle(boolean isIdle) throws Exception {
+        setAppIdleNoAssert(isIdle);
+        assertAppIdle(isIdle);
+    }
+
+    protected void setAppIdleNoAssert(boolean isIdle) throws Exception {
         if (!isAppStandbySupported()) {
             return;
         }
-        Log.i(TAG, "Setting app idle to " + enabled);
-        executeSilentShellCommand("am set-inactive " + TEST_APP2_PKG + " " + enabled );
-        assertAppIdle(enabled);
+        Log.i(TAG, "Setting app idle to " + isIdle);
+        final String bucketName = isIdle ? "rare" : "active";
+        executeSilentShellCommand("am set-standby-bucket " + TEST_APP2_PKG + " " + bucketName);
     }
 
-    protected void setAppIdleNoAssert(boolean enabled) throws Exception {
-        if (!isAppStandbySupported()) {
-            return;
-        }
-        Log.i(TAG, "Setting app idle to " + enabled);
-        executeSilentShellCommand("am set-inactive " + TEST_APP2_PKG + " " + enabled );
-    }
-
-    protected void assertAppIdle(boolean enabled) throws Exception {
+    protected void assertAppIdle(boolean isIdle) throws Exception {
         try {
             assertDelayedShellCommand("am get-inactive " + TEST_APP2_PKG,
-                    30 /* maxTries */, 1 /* napTimeSeconds */, "Idle=" + enabled);
+                    30 /* maxTries */, 1 /* napTimeSeconds */, "Idle=" + isIdle);
         } catch (Throwable e) {
             throw e;
         }
diff --git a/tests/cts/hostside/app/src/com/android/cts/net/hostside/MyServiceClient.java b/tests/cts/hostside/app/src/com/android/cts/net/hostside/MyServiceClient.java
index 0610774..93cc911 100644
--- a/tests/cts/hostside/app/src/com/android/cts/net/hostside/MyServiceClient.java
+++ b/tests/cts/hostside/app/src/com/android/cts/net/hostside/MyServiceClient.java
@@ -27,7 +27,7 @@
 import android.os.RemoteException;
 
 public class MyServiceClient {
-    private static final int TIMEOUT_MS = 5000;
+    private static final int TIMEOUT_MS = 20_000;
     private static final String PACKAGE = MyServiceClient.class.getPackage().getName();
     private static final String APP2_PACKAGE = PACKAGE + ".app2";
     private static final String SERVICE_NAME = APP2_PACKAGE + ".MyService";
diff --git a/tests/cts/hostside/app/src/com/android/cts/net/hostside/NetworkCallbackTest.java b/tests/cts/hostside/app/src/com/android/cts/net/hostside/NetworkCallbackTest.java
index 0715e32..ab3cf14 100644
--- a/tests/cts/hostside/app/src/com/android/cts/net/hostside/NetworkCallbackTest.java
+++ b/tests/cts/hostside/app/src/com/android/cts/net/hostside/NetworkCallbackTest.java
@@ -236,16 +236,19 @@
             // Enable restrict background
             setRestrictBackground(true);
             assertBackgroundNetworkAccess(false);
+            assertNetworkAccessBlockedByBpf(true, mUid, true /* metered */);
             mTestNetworkCallback.expectBlockedStatusCallbackEventually(mNetwork, true);
 
             // Add to whitelist
             addRestrictBackgroundWhitelist(mUid);
             assertBackgroundNetworkAccess(true);
+            assertNetworkAccessBlockedByBpf(false, mUid, true /* metered */);
             mTestNetworkCallback.expectBlockedStatusCallbackEventually(mNetwork, false);
 
             // Remove from whitelist
             removeRestrictBackgroundWhitelist(mUid);
             assertBackgroundNetworkAccess(false);
+            assertNetworkAccessBlockedByBpf(true, mUid, true /* metered */);
             mTestNetworkCallback.expectBlockedStatusCallbackEventually(mNetwork, true);
         } finally {
             mMeterednessConfiguration.resetNetworkMeteredness();
@@ -257,11 +260,13 @@
                 true /* hasCapability */, NET_CAPABILITY_NOT_METERED);
         try {
             assertBackgroundNetworkAccess(true);
+            assertNetworkAccessBlockedByBpf(false, mUid, false /* metered */);
             mTestNetworkCallback.expectBlockedStatusCallbackEventually(mNetwork, false);
 
             // Disable restrict background, should not trigger callback
             setRestrictBackground(false);
             assertBackgroundNetworkAccess(true);
+            assertNetworkAccessBlockedByBpf(false, mUid, false /* metered */);
         } finally {
             mMeterednessConfiguration.resetNetworkMeteredness();
         }
@@ -275,11 +280,13 @@
             setBatterySaverMode(true);
             assertBackgroundNetworkAccess(false);
             mTestNetworkCallback.expectBlockedStatusCallbackEventually(mNetwork, true);
+            assertNetworkAccessBlockedByBpf(true, mUid, true /* metered */);
 
             // Disable Power Saver
             setBatterySaverMode(false);
             assertBackgroundNetworkAccess(true);
             mTestNetworkCallback.expectBlockedStatusCallbackEventually(mNetwork, false);
+            assertNetworkAccessBlockedByBpf(false, mUid, true /* metered */);
         } finally {
             mMeterednessConfiguration.resetNetworkMeteredness();
         }
@@ -293,11 +300,13 @@
             setBatterySaverMode(true);
             assertBackgroundNetworkAccess(false);
             mTestNetworkCallback.expectBlockedStatusCallbackEventually(mNetwork, true);
+            assertNetworkAccessBlockedByBpf(true, mUid, false /* metered */);
 
             // Disable Power Saver
             setBatterySaverMode(false);
             assertBackgroundNetworkAccess(true);
             mTestNetworkCallback.expectBlockedStatusCallbackEventually(mNetwork, false);
+            assertNetworkAccessBlockedByBpf(false, mUid, false /* metered */);
         } finally {
             mMeterednessConfiguration.resetNetworkMeteredness();
         }
diff --git a/tests/cts/hostside/app/src/com/android/cts/net/hostside/NetworkPolicyTestUtils.java b/tests/cts/hostside/app/src/com/android/cts/net/hostside/NetworkPolicyTestUtils.java
index 8c38b44..5331601 100644
--- a/tests/cts/hostside/app/src/com/android/cts/net/hostside/NetworkPolicyTestUtils.java
+++ b/tests/cts/hostside/app/src/com/android/cts/net/hostside/NetworkPolicyTestUtils.java
@@ -449,13 +449,19 @@
     // this function and using PollingCheck to try to make sure the uid has updated and reduce the
     // flaky rate.
     public static void assertNetworkingBlockedStatusForUid(int uid, boolean metered,
-            boolean expectedResult) throws Exception {
-        PollingCheck.waitFor(() -> (expectedResult == isUidNetworkingBlocked(uid, metered)));
+            boolean expectedResult) {
+        final String errMsg = String.format("Unexpected result from isUidNetworkingBlocked; "
+                + "uid= " + uid + ", metered=" + metered + ", expected=" + expectedResult);
+        PollingCheck.waitFor(() -> (expectedResult == isUidNetworkingBlocked(uid, metered)),
+                errMsg);
     }
 
-    public static void assertIsUidRestrictedOnMeteredNetworks(int uid, boolean expectedResult)
-            throws Exception {
-        PollingCheck.waitFor(() -> (expectedResult == isUidRestrictedOnMeteredNetworks(uid)));
+    public static void assertIsUidRestrictedOnMeteredNetworks(int uid, boolean expectedResult) {
+        final String errMsg = String.format(
+                "Unexpected result from isUidRestrictedOnMeteredNetworks; "
+                + "uid= " + uid + ", expected=" + expectedResult);
+        PollingCheck.waitFor(() -> (expectedResult == isUidRestrictedOnMeteredNetworks(uid)),
+                errMsg);
     }
 
     public static boolean isUidNetworkingBlocked(int uid, boolean meteredNetwork) {
diff --git a/tests/cts/hostside/app2/AndroidManifest.xml b/tests/cts/hostside/app2/AndroidManifest.xml
index ff7240d..2c2d957 100644
--- a/tests/cts/hostside/app2/AndroidManifest.xml
+++ b/tests/cts/hostside/app2/AndroidManifest.xml
@@ -20,6 +20,7 @@
 
     <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
     <uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
+    <uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"/>
     <uses-permission android:name="android.permission.INTERNET"/>
     <uses-permission android:name="android.permission.PACKAGE_USAGE_STATS" />
     <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
@@ -45,7 +46,11 @@
         <service android:name=".MyService"
              android:exported="true"/>
         <service android:name=".MyForegroundService"
-             android:exported="true"/>
+             android:foregroundServiceType="specialUse"
+             android:exported="true">
+            <property android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE" 
+                      android:value="Connectivity" />
+        </service>
         <service android:name=".RemoteSocketFactoryService"
              android:exported="true"/>
 
diff --git a/tests/cts/net/Android.bp b/tests/cts/net/Android.bp
index 6de663a..b86de25 100644
--- a/tests/cts/net/Android.bp
+++ b/tests/cts/net/Android.bp
@@ -55,7 +55,7 @@
         "junit-params",
         "modules-utils-build",
         "net-utils-framework-common",
-        "truth-prebuilt",
+        "truth",
         "TetheringIntegrationTestsBaseLib",
     ],
 
@@ -74,7 +74,10 @@
 // devices.
 android_test {
     name: "CtsNetTestCases",
-    defaults: ["CtsNetTestCasesDefaults", "ConnectivityNextEnableDefaults"],
+    defaults: [
+        "CtsNetTestCasesDefaults",
+        "ConnectivityNextEnableDefaults",
+    ],
     static_libs: [
         "DhcpPacketLib",
         "NetworkStackApiCurrentShims",
@@ -129,7 +132,7 @@
 }
 
 android_test {
-    name: "CtsNetTestCasesMaxTargetSdk33",  // Must match CtsNetTestCasesMaxTargetSdk33 annotation.
+    name: "CtsNetTestCasesMaxTargetSdk33", // Must match CtsNetTestCasesMaxTargetSdk33 annotation.
     defaults: ["CtsNetTestCasesMaxTargetSdkDefaults"],
     target_sdk_version: "33",
     package_name: "android.net.cts.maxtargetsdk33",
@@ -137,17 +140,17 @@
 }
 
 android_test {
-    name: "CtsNetTestCasesMaxTargetSdk31",  // Must match CtsNetTestCasesMaxTargetSdk31 annotation.
+    name: "CtsNetTestCasesMaxTargetSdk31", // Must match CtsNetTestCasesMaxTargetSdk31 annotation.
     defaults: ["CtsNetTestCasesMaxTargetSdkDefaults"],
     target_sdk_version: "31",
-    package_name: "android.net.cts.maxtargetsdk31",  // CTS package names must be unique.
+    package_name: "android.net.cts.maxtargetsdk31", // CTS package names must be unique.
     instrumentation_target_package: "android.net.cts.maxtargetsdk31",
 }
 
 android_test {
-    name: "CtsNetTestCasesMaxTargetSdk30",  // Must match CtsNetTestCasesMaxTargetSdk30 annotation.
+    name: "CtsNetTestCasesMaxTargetSdk30", // Must match CtsNetTestCasesMaxTargetSdk30 annotation.
     defaults: ["CtsNetTestCasesMaxTargetSdkDefaults"],
     target_sdk_version: "30",
-    package_name: "android.net.cts.maxtargetsdk30",  // CTS package names must be unique.
+    package_name: "android.net.cts.maxtargetsdk30", // CTS package names must be unique.
     instrumentation_target_package: "android.net.cts.maxtargetsdk30",
 }
diff --git a/tests/cts/net/api23Test/Android.bp b/tests/cts/net/api23Test/Android.bp
index 9b81a56..1f1dd5d 100644
--- a/tests/cts/net/api23Test/Android.bp
+++ b/tests/cts/net/api23Test/Android.bp
@@ -41,7 +41,7 @@
         "mockwebserver",
         "junit",
         "junit-params",
-        "truth-prebuilt",
+        "truth",
     ],
 
     platform_apis: true,
diff --git a/tests/cts/net/src/android/net/cts/ConnectivityManagerTest.java b/tests/cts/net/src/android/net/cts/ConnectivityManagerTest.java
index 59aefa5..62614c1 100644
--- a/tests/cts/net/src/android/net/cts/ConnectivityManagerTest.java
+++ b/tests/cts/net/src/android/net/cts/ConnectivityManagerTest.java
@@ -47,6 +47,7 @@
 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_DEFAULT;
 import static android.net.ConnectivityManager.FIREWALL_RULE_DENY;
 import static android.net.ConnectivityManager.PROFILE_NETWORK_PREFERENCE_ENTERPRISE;
 import static android.net.ConnectivityManager.TYPE_BLUETOOTH;
@@ -2723,7 +2724,8 @@
             // the network with the TEST transport. Also wait for validation here, in case there
             // is a bug that's only visible when the network is validated.
             setWifiMeteredStatusAndWait(ssid, true /* isMetered */, true /* waitForValidation */);
-            defaultCallback.expect(CallbackEntry.LOST, wifiNetwork, NETWORK_CALLBACK_TIMEOUT_MS);
+            defaultCallback.eventuallyExpect(CallbackEntry.LOST, NETWORK_CALLBACK_TIMEOUT_MS,
+                    l -> l.getNetwork().equals(wifiNetwork));
             waitForAvailable(defaultCallback, tnt.getNetwork());
             // Depending on if this device has cellular connectivity or not, multiple available
             // callbacks may be received. Eventually, metered Wi-Fi should be the final available
@@ -3590,6 +3592,15 @@
         }
     }
 
+    private void setUidFirewallRule(final int chain, final int uid, final int rule) {
+        try {
+            mCm.setUidFirewallRule(chain, uid, rule);
+        } catch (IllegalStateException ignored) {
+            // Removing match causes an exception when the rule entry for the uid does
+            // not exist. But this is fine and can be ignored.
+        }
+    }
+
     private static final boolean EXPECT_OPEN = false;
     private static final boolean EXPECT_CLOSE = true;
 
@@ -3598,6 +3609,8 @@
         runWithShellPermissionIdentity(() -> {
             // Firewall chain status will be restored after the test.
             final boolean wasChainEnabled = mCm.getFirewallChainEnabled(chain);
+            final int myUid = Process.myUid();
+            final int previousMyUidFirewallRule = mCm.getUidFirewallRule(chain, myUid);
             final int previousUidFirewallRule = mCm.getUidFirewallRule(chain, targetUid);
             final Socket socket = new Socket(TEST_HOST, HTTP_PORT);
             socket.setSoTimeout(NETWORK_REQUEST_TIMEOUT_MS);
@@ -3605,12 +3618,12 @@
                 mCm.setFirewallChainEnabled(chain, false /* enable */);
                 assertSocketOpen(socket);
 
-                try {
-                    mCm.setUidFirewallRule(chain, targetUid, rule);
-                } catch (IllegalStateException ignored) {
-                    // Removing match causes an exception when the rule entry for the uid does
-                    // not exist. But this is fine and can be ignored.
+                setUidFirewallRule(chain, targetUid, rule);
+                if (targetUid != myUid) {
+                    // If this test does not set rule on myUid, remove existing rule on myUid
+                    setUidFirewallRule(chain, myUid, FIREWALL_RULE_DEFAULT);
                 }
+
                 mCm.setFirewallChainEnabled(chain, true /* enable */);
 
                 if (expectClose) {
@@ -3623,11 +3636,9 @@
                     mCm.setFirewallChainEnabled(chain, wasChainEnabled);
                 }, /* cleanup */ () -> {
                     // Restore the uid firewall rule status
-                    try {
-                        mCm.setUidFirewallRule(chain, targetUid, previousUidFirewallRule);
-                    } catch (IllegalStateException ignored) {
-                        // Removing match causes an exception when the rule entry for the uid does
-                        // not exist. But this is fine and can be ignored.
+                    setUidFirewallRule(chain, targetUid, previousUidFirewallRule);
+                    if (targetUid != myUid) {
+                        setUidFirewallRule(chain, myUid, previousMyUidFirewallRule);
                     }
                 }, /* cleanup */ () -> {
                     socket.close();
diff --git a/tests/cts/net/src/android/net/cts/Ikev2VpnTest.java b/tests/cts/net/src/android/net/cts/Ikev2VpnTest.java
index 805dd65..6b7954a 100644
--- a/tests/cts/net/src/android/net/cts/Ikev2VpnTest.java
+++ b/tests/cts/net/src/android/net/cts/Ikev2VpnTest.java
@@ -22,6 +22,7 @@
 import static android.net.cts.util.CtsNetUtils.TestNetworkCallback;
 
 import static com.android.compatibility.common.util.SystemUtil.runWithShellPermissionIdentity;
+import static com.android.modules.utils.build.SdkLevel.isAtLeastU;
 import static com.android.modules.utils.build.SdkLevel.isAtLeastT;
 import static com.android.testutils.DevSdkIgnoreRuleKt.SC_V2;
 
@@ -34,12 +35,14 @@
 import static org.junit.Assert.assertTrue;
 import static org.junit.Assert.fail;
 import static org.junit.Assume.assumeTrue;
+import static org.junit.Assume.assumeFalse;
 
 import android.Manifest;
 import android.annotation.NonNull;
 import android.app.AppOpsManager;
 import android.content.Context;
 import android.content.Intent;
+import android.content.pm.PackageManager;
 import android.net.ConnectivityManager;
 import android.net.Ikev2VpnProfile;
 import android.net.IpSecAlgorithm;
@@ -60,11 +63,7 @@
 
 import com.android.internal.util.HexDump;
 import com.android.networkstack.apishim.ConstantsShim;
-import com.android.networkstack.apishim.Ikev2VpnProfileBuilderShimImpl;
-import com.android.networkstack.apishim.Ikev2VpnProfileShimImpl;
 import com.android.networkstack.apishim.VpnManagerShimImpl;
-import com.android.networkstack.apishim.common.Ikev2VpnProfileBuilderShim;
-import com.android.networkstack.apishim.common.Ikev2VpnProfileShim;
 import com.android.networkstack.apishim.common.VpnManagerShim;
 import com.android.networkstack.apishim.common.VpnProfileStateShim;
 import com.android.testutils.DevSdkIgnoreRule;
@@ -75,6 +74,7 @@
 
 import org.bouncycastle.x509.X509V1CertificateGenerator;
 import org.junit.After;
+import org.junit.Before;
 import org.junit.Rule;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -203,6 +203,12 @@
         mUserCertKey = generateRandomCertAndKeyPair();
     }
 
+    @Before
+    public void setUp() {
+        assumeFalse("Skipping test because watches don't support VPN",
+            sContext.getPackageManager().hasSystemFeature(PackageManager.FEATURE_WATCH));
+    }
+
     @After
     public void tearDown() {
         for (TestableNetworkCallback callback : mCallbacksToUnregister) {
@@ -210,6 +216,15 @@
         }
         setAppop(AppOpsManager.OP_ACTIVATE_VPN, false);
         setAppop(AppOpsManager.OP_ACTIVATE_PLATFORM_VPN, false);
+
+        // Make sure the VpnProfile is not provisioned already.
+        sVpnMgr.stopProvisionedVpnProfile();
+
+        try {
+            sVpnMgr.startProvisionedVpnProfile();
+            fail("Expected SecurityException for missing consent");
+        } catch (SecurityException expected) {
+        }
     }
 
     /**
@@ -227,28 +242,25 @@
     }
 
     private Ikev2VpnProfile buildIkev2VpnProfileCommon(
-            @NonNull Ikev2VpnProfileBuilderShim builderShim, boolean isRestrictedToTestNetworks,
+            @NonNull Ikev2VpnProfile.Builder builder, boolean isRestrictedToTestNetworks,
             boolean requiresValidation, boolean automaticIpVersionSelectionEnabled,
             boolean automaticNattKeepaliveTimerEnabled) throws Exception {
 
-        builderShim.setBypassable(true)
+        builder.setBypassable(true)
                 .setAllowedAlgorithms(TEST_ALLOWED_ALGORITHMS)
                 .setProxy(TEST_PROXY_INFO)
                 .setMaxMtu(TEST_MTU)
                 .setMetered(false);
-        if (TestUtils.shouldTestTApis()) {
-            builderShim.setRequiresInternetValidation(requiresValidation);
+        if (isAtLeastT()) {
+            builder.setRequiresInternetValidation(requiresValidation);
         }
 
-        if (TestUtils.shouldTestUApis()) {
-            builderShim.setAutomaticIpVersionSelectionEnabled(automaticIpVersionSelectionEnabled);
-            builderShim.setAutomaticNattKeepaliveTimerEnabled(automaticNattKeepaliveTimerEnabled);
+        if (isAtLeastU()) {
+            builder.setAutomaticIpVersionSelectionEnabled(automaticIpVersionSelectionEnabled);
+            builder.setAutomaticNattKeepaliveTimerEnabled(automaticNattKeepaliveTimerEnabled);
         }
 
-        // Convert shim back to Ikev2VpnProfile.Builder since restrictToTestNetworks is a hidden
-        // method and is not defined in shims.
         // TODO: replace it in alternative way to remove the hidden method usage
-        final Ikev2VpnProfile.Builder builder = (Ikev2VpnProfile.Builder) builderShim.getBuilder();
         if (isRestrictedToTestNetworks) {
             builder.restrictToTestNetworks();
         }
@@ -264,16 +276,14 @@
                         ? IkeSessionTestUtils.IKE_PARAMS_V6 : IkeSessionTestUtils.IKE_PARAMS_V4,
                         IkeSessionTestUtils.CHILD_PARAMS);
 
-        final Ikev2VpnProfileBuilderShim builderShim =
-                Ikev2VpnProfileBuilderShimImpl.newInstance(params)
+        final Ikev2VpnProfile.Builder builder =
+                new Ikev2VpnProfile.Builder(params)
                         .setRequiresInternetValidation(requiresValidation)
                         .setProxy(TEST_PROXY_INFO)
                         .setMaxMtu(TEST_MTU)
                         .setMetered(false);
-        // Convert shim back to Ikev2VpnProfile.Builder since restrictToTestNetworks is a hidden
-        // method and is not defined in shims.
+
         // TODO: replace it in alternative way to remove the hidden method usage
-        final Ikev2VpnProfile.Builder builder = (Ikev2VpnProfile.Builder) builderShim.getBuilder();
         if (isRestrictedToTestNetworks) {
             builder.restrictToTestNetworks();
         }
@@ -283,8 +293,8 @@
     private Ikev2VpnProfile buildIkev2VpnProfilePsk(@NonNull String remote,
             boolean isRestrictedToTestNetworks, boolean requiresValidation)
             throws Exception {
-        final Ikev2VpnProfileBuilderShim builder =
-                Ikev2VpnProfileBuilderShimImpl.newInstance(remote, TEST_IDENTITY)
+        final Ikev2VpnProfile.Builder builder =
+                new Ikev2VpnProfile.Builder(remote, TEST_IDENTITY)
                         .setAuthPsk(TEST_PSK);
         return buildIkev2VpnProfileCommon(builder, isRestrictedToTestNetworks,
                 requiresValidation, false /* automaticIpVersionSelectionEnabled */,
@@ -293,8 +303,8 @@
 
     private Ikev2VpnProfile buildIkev2VpnProfileUsernamePassword(boolean isRestrictedToTestNetworks)
             throws Exception {
-        final Ikev2VpnProfileBuilderShim builder =
-                Ikev2VpnProfileBuilderShimImpl.newInstance(TEST_SERVER_ADDR_V6, TEST_IDENTITY)
+        final Ikev2VpnProfile.Builder builder =
+                new Ikev2VpnProfile.Builder(TEST_SERVER_ADDR_V6, TEST_IDENTITY)
                         .setAuthUsernamePassword(TEST_USER, TEST_PASSWORD, mServerRootCa);
         return buildIkev2VpnProfileCommon(builder, isRestrictedToTestNetworks,
                 false /* requiresValidation */, false /* automaticIpVersionSelectionEnabled */,
@@ -303,8 +313,8 @@
 
     private Ikev2VpnProfile buildIkev2VpnProfileDigitalSignature(boolean isRestrictedToTestNetworks)
             throws Exception {
-        final Ikev2VpnProfileBuilderShim builder =
-                Ikev2VpnProfileBuilderShimImpl.newInstance(TEST_SERVER_ADDR_V6, TEST_IDENTITY)
+        final Ikev2VpnProfile.Builder builder =
+                new Ikev2VpnProfile.Builder(TEST_SERVER_ADDR_V6, TEST_IDENTITY)
                         .setAuthDigitalSignature(
                                 mUserCertKey.cert, mUserCertKey.key, mServerRootCa);
         return buildIkev2VpnProfileCommon(builder, isRestrictedToTestNetworks,
@@ -347,7 +357,6 @@
     @Test
     public void testBuildIkev2VpnProfileWithIkeTunnelConnectionParams() throws Exception {
         assumeTrue(mCtsNetUtils.hasIpsecTunnelsFeature());
-        assumeTrue(TestUtils.shouldTestTApis());
 
         final IkeTunnelConnectionParams expectedParams = new IkeTunnelConnectionParams(
                 IkeSessionTestUtils.IKE_PARAMS_V6, IkeSessionTestUtils.CHILD_PARAMS);
@@ -567,7 +576,7 @@
         // regardless of its value. However, there is a race in Vpn(see b/228574221) that VPN may
         // misuse VPN network itself as the underlying network. The fix is not available without
         // SDK > T platform. Thus, verify this only on T+ platform.
-        if (!requiresValidation && TestUtils.shouldTestTApis()) {
+        if (!requiresValidation && isAtLeastT()) {
             cb.eventuallyExpect(CallbackEntry.NETWORK_CAPS_UPDATED, TIMEOUT_MS,
                     entry -> ((CallbackEntry.CapabilitiesChanged) entry).getCaps()
                             .hasCapability(NET_CAPABILITY_VALIDATED));
@@ -647,7 +656,6 @@
 
     @Test @IgnoreUpTo(SC_V2)
     public void testStartStopVpnProfileV4WithValidation() throws Exception {
-        assumeTrue(TestUtils.shouldTestTApis());
         doTestStartStopVpnProfile(false /* testIpv6Only */, true /* requiresValidation */,
                 false /* testSessionKey */, false /* testIkeTunConnParams */);
     }
@@ -660,35 +668,30 @@
 
     @Test @IgnoreUpTo(SC_V2)
     public void testStartStopVpnProfileV6WithValidation() throws Exception {
-        assumeTrue(TestUtils.shouldTestTApis());
         doTestStartStopVpnProfile(true /* testIpv6Only */, true /* requiresValidation */,
                 false /* testSessionKey */, false /* testIkeTunConnParams */);
     }
 
     @Test @IgnoreUpTo(SC_V2)
     public void testStartStopVpnProfileIkeTunConnParamsV4() throws Exception {
-        assumeTrue(TestUtils.shouldTestTApis());
         doTestStartStopVpnProfile(false /* testIpv6Only */, false /* requiresValidation */,
                 false /* testSessionKey */, true /* testIkeTunConnParams */);
     }
 
     @Test @IgnoreUpTo(SC_V2)
     public void testStartStopVpnProfileIkeTunConnParamsV4WithValidation() throws Exception {
-        assumeTrue(TestUtils.shouldTestTApis());
         doTestStartStopVpnProfile(false /* testIpv6Only */, true /* requiresValidation */,
                 false /* testSessionKey */, true /* testIkeTunConnParams */);
     }
 
     @Test @IgnoreUpTo(SC_V2)
     public void testStartStopVpnProfileIkeTunConnParamsV6() throws Exception {
-        assumeTrue(TestUtils.shouldTestTApis());
         doTestStartStopVpnProfile(true /* testIpv6Only */, false /* requiresValidation */,
                 false /* testSessionKey */, true /* testIkeTunConnParams */);
     }
 
     @Test @IgnoreUpTo(SC_V2)
     public void testStartStopVpnProfileIkeTunConnParamsV6WithValidation() throws Exception {
-        assumeTrue(TestUtils.shouldTestTApis());
         doTestStartStopVpnProfile(true /* testIpv6Only */, true /* requiresValidation */,
                 false /* testSessionKey */, true /* testIkeTunConnParams */);
     }
@@ -696,7 +699,6 @@
     @IgnoreUpTo(SC_V2)
     @Test
     public void testStartProvisionedVpnV4ProfileSession() throws Exception {
-        assumeTrue(TestUtils.shouldTestTApis());
         doTestStartStopVpnProfile(false /* testIpv6Only */, false /* requiresValidation */,
                 true /* testSessionKey */, false /* testIkeTunConnParams */);
     }
@@ -704,59 +706,44 @@
     @IgnoreUpTo(SC_V2)
     @Test
     public void testStartProvisionedVpnV6ProfileSession() throws Exception {
-        assumeTrue(TestUtils.shouldTestTApis());
         doTestStartStopVpnProfile(true /* testIpv6Only */, false /* requiresValidation */,
                 true /* testSessionKey */, false /* testIkeTunConnParams */);
     }
 
+    @IgnoreUpTo(Build.VERSION_CODES.TIRAMISU)
     @Test
     public void testBuildIkev2VpnProfileWithAutomaticNattKeepaliveTimerEnabled() throws Exception {
-        // Cannot use @IgnoreUpTo(Build.VERSION_CODES.TIRAMISU) because this test also requires API
-        // 34 shims, and @IgnoreUpTo does not check that.
-        assumeTrue(TestUtils.shouldTestUApis());
-
         final Ikev2VpnProfile profileWithDefaultValue = buildIkev2VpnProfilePsk(TEST_SERVER_ADDR_V6,
                 false /* isRestrictedToTestNetworks */, false /* requiresValidation */);
-        final Ikev2VpnProfileShim<Ikev2VpnProfile> shimWithDefaultValue =
-                Ikev2VpnProfileShimImpl.newInstance(profileWithDefaultValue);
-        assertFalse(shimWithDefaultValue.isAutomaticNattKeepaliveTimerEnabled());
+        assertFalse(profileWithDefaultValue.isAutomaticNattKeepaliveTimerEnabled());
 
-        final Ikev2VpnProfileBuilderShim builder =
-                Ikev2VpnProfileBuilderShimImpl.newInstance(TEST_SERVER_ADDR_V6, TEST_IDENTITY)
+        final Ikev2VpnProfile.Builder builder =
+                new Ikev2VpnProfile.Builder(TEST_SERVER_ADDR_V6, TEST_IDENTITY)
                         .setAuthPsk(TEST_PSK);
         final Ikev2VpnProfile profile = buildIkev2VpnProfileCommon(builder,
                 false /* isRestrictedToTestNetworks */,
                 false /* requiresValidation */,
                 false /* automaticIpVersionSelectionEnabled */,
                 true /* automaticNattKeepaliveTimerEnabled */);
-        final Ikev2VpnProfileShim<Ikev2VpnProfile> shim =
-                Ikev2VpnProfileShimImpl.newInstance(profile);
-        assertTrue(shim.isAutomaticNattKeepaliveTimerEnabled());
+        assertTrue(profile.isAutomaticNattKeepaliveTimerEnabled());
     }
 
+    @IgnoreUpTo(Build.VERSION_CODES.TIRAMISU)
     @Test
     public void testBuildIkev2VpnProfileWithAutomaticIpVersionSelectionEnabled() throws Exception {
-        // Cannot use @IgnoreUpTo(Build.VERSION_CODES.TIRAMISU) because this test also requires API
-        // 34 shims, and @IgnoreUpTo does not check that.
-        assumeTrue(TestUtils.shouldTestUApis());
-
         final Ikev2VpnProfile profileWithDefaultValue = buildIkev2VpnProfilePsk(TEST_SERVER_ADDR_V6,
                 false /* isRestrictedToTestNetworks */, false /* requiresValidation */);
-        final Ikev2VpnProfileShim<Ikev2VpnProfile> shimWithDefaultValue =
-                Ikev2VpnProfileShimImpl.newInstance(profileWithDefaultValue);
-        assertFalse(shimWithDefaultValue.isAutomaticIpVersionSelectionEnabled());
+        assertFalse(profileWithDefaultValue.isAutomaticIpVersionSelectionEnabled());
 
-        final Ikev2VpnProfileBuilderShim builder =
-                Ikev2VpnProfileBuilderShimImpl.newInstance(TEST_SERVER_ADDR_V6, TEST_IDENTITY)
+        final Ikev2VpnProfile.Builder builder =
+                new Ikev2VpnProfile.Builder(TEST_SERVER_ADDR_V6, TEST_IDENTITY)
                         .setAuthPsk(TEST_PSK);
         final Ikev2VpnProfile profile = buildIkev2VpnProfileCommon(builder,
                 false /* isRestrictedToTestNetworks */,
                 false /* requiresValidation */,
                 true /* automaticIpVersionSelectionEnabled */,
                 false /* automaticNattKeepaliveTimerEnabled */);
-        final Ikev2VpnProfileShim<Ikev2VpnProfile> shim =
-                Ikev2VpnProfileShimImpl.newInstance(profile);
-        assertTrue(shim.isAutomaticIpVersionSelectionEnabled());
+        assertTrue(profile.isAutomaticIpVersionSelectionEnabled());
     }
 
     private static class CertificateAndKey {
diff --git a/tests/cts/net/src/android/net/cts/MdnsTestUtils.kt b/tests/cts/net/src/android/net/cts/MdnsTestUtils.kt
index bc13442..eef3f87 100644
--- a/tests/cts/net/src/android/net/cts/MdnsTestUtils.kt
+++ b/tests/cts/net/src/android/net/cts/MdnsTestUtils.kt
@@ -233,46 +233,51 @@
     }
 }
 
+private fun getMdnsPayload(packet: ByteArray) = packet.copyOfRange(
+    ETHER_HEADER_LEN + IPV6_HEADER_LEN + UDP_HEADER_LEN, packet.size)
+
 fun TapPacketReader.pollForMdnsPacket(
     timeoutMs: Long = MDNS_REGISTRATION_TIMEOUT_MS,
     predicate: (TestDnsPacket) -> Boolean
-): ByteArray? {
+): TestDnsPacket? {
     val mdnsProbeFilter = IPv6UdpFilter(srcPort = MDNS_PORT, dstPort = MDNS_PORT).and {
-        val mdnsPayload = it.copyOfRange(
-            ETHER_HEADER_LEN + IPV6_HEADER_LEN + UDP_HEADER_LEN, it.size
-        )
+        val mdnsPayload = getMdnsPayload(it)
         try {
             predicate(TestDnsPacket(mdnsPayload))
         } catch (e: DnsPacket.ParseException) {
             false
         }
     }
-    return poll(timeoutMs, mdnsProbeFilter)
+    return poll(timeoutMs, mdnsProbeFilter)?.let { TestDnsPacket(getMdnsPayload(it)) }
 }
 
 fun TapPacketReader.pollForProbe(
     serviceName: String,
     serviceType: String,
     timeoutMs: Long = MDNS_REGISTRATION_TIMEOUT_MS
-): ByteArray? = pollForMdnsPacket(timeoutMs) { it.isProbeFor("$serviceName.$serviceType.local") }
+): TestDnsPacket? = pollForMdnsPacket(timeoutMs) {
+    it.isProbeFor("$serviceName.$serviceType.local")
+}
 
 fun TapPacketReader.pollForAdvertisement(
     serviceName: String,
     serviceType: String,
     timeoutMs: Long = MDNS_REGISTRATION_TIMEOUT_MS
-): ByteArray? = pollForMdnsPacket(timeoutMs) { it.isReplyFor("$serviceName.$serviceType.local") }
+): TestDnsPacket? = pollForMdnsPacket(timeoutMs) {
+    it.isReplyFor("$serviceName.$serviceType.local")
+}
 
 fun TapPacketReader.pollForQuery(
     recordName: String,
-    recordType: Int,
+    vararg requiredTypes: Int,
     timeoutMs: Long = MDNS_REGISTRATION_TIMEOUT_MS
-): ByteArray? = pollForMdnsPacket(timeoutMs) { it.isQueryFor(recordName, recordType) }
+): TestDnsPacket? = pollForMdnsPacket(timeoutMs) { it.isQueryFor(recordName, *requiredTypes) }
 
 fun TapPacketReader.pollForReply(
     serviceName: String,
     serviceType: String,
     timeoutMs: Long = MDNS_REGISTRATION_TIMEOUT_MS
-): ByteArray? = pollForMdnsPacket(timeoutMs) {
+): TestDnsPacket? = pollForMdnsPacket(timeoutMs) {
     it.isReplyFor("$serviceName.$serviceType.local")
 }
 
@@ -289,7 +294,9 @@
         it.dName == name && it.nsType == DnsResolver.TYPE_SRV
     }
 
-    fun isQueryFor(name: String, type: Int): Boolean = mRecords[QDSECTION].any {
-        it.dName == name && it.nsType == type
+    fun isQueryFor(name: String, vararg requiredTypes: Int): Boolean = requiredTypes.all { type ->
+        mRecords[QDSECTION].any {
+            it.dName == name && it.nsType == type
+        }
     }
 }
diff --git a/tests/cts/net/src/android/net/cts/NetworkAgentTest.kt b/tests/cts/net/src/android/net/cts/NetworkAgentTest.kt
index 5937655..392cba9 100644
--- a/tests/cts/net/src/android/net/cts/NetworkAgentTest.kt
+++ b/tests/cts/net/src/android/net/cts/NetworkAgentTest.kt
@@ -704,6 +704,7 @@
                 argThat<NetworkInfo> { it.detailedState == NetworkInfo.DetailedState.CONNECTING },
                 any(LinkProperties::class.java),
                 any(NetworkCapabilities::class.java),
+                any(), // LocalNetworkConfig TODO : specify when it's public
                 any(NetworkScore::class.java),
                 any(NetworkAgentConfig::class.java),
                 eq(NetworkProvider.ID_NONE))
diff --git a/tests/cts/net/src/android/net/cts/NetworkRequestTest.java b/tests/cts/net/src/android/net/cts/NetworkRequestTest.java
index 637ed26..594f3fb 100644
--- a/tests/cts/net/src/android/net/cts/NetworkRequestTest.java
+++ b/tests/cts/net/src/android/net/cts/NetworkRequestTest.java
@@ -19,8 +19,10 @@
 import static android.net.NetworkCapabilities.NET_CAPABILITY_DUN;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_FOTA;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET;
+import static android.net.NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_MMS;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING;
+import static android.net.NetworkCapabilities.NET_CAPABILITY_NOT_VPN;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_SUPL;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_TEMPORARILY_NOT_METERED;
 import static android.net.NetworkCapabilities.TRANSPORT_BLUETOOTH;
@@ -28,6 +30,8 @@
 import static android.net.NetworkCapabilities.TRANSPORT_VPN;
 import static android.net.NetworkCapabilities.TRANSPORT_WIFI;
 
+import static com.android.testutils.DevSdkIgnoreRuleKt.VANILLA_ICE_CREAM;
+
 import static junit.framework.Assert.fail;
 
 import static org.junit.Assert.assertArrayEquals;
@@ -104,6 +108,23 @@
         verifyNoCapabilities(nr);
     }
 
+    @Test @IgnoreUpTo(Build.VERSION_CODES.R)
+    public void testForbiddenCapabilities() {
+        final NetworkRequest.Builder builder = new NetworkRequest.Builder();
+        builder.addForbiddenCapability(NET_CAPABILITY_MMS);
+        assertTrue(builder.build().hasForbiddenCapability(NET_CAPABILITY_MMS));
+        builder.removeForbiddenCapability(NET_CAPABILITY_MMS);
+        assertFalse(builder.build().hasCapability(NET_CAPABILITY_MMS));
+        builder.addCapability(NET_CAPABILITY_MMS);
+        assertFalse(builder.build().hasForbiddenCapability(NET_CAPABILITY_MMS));
+        assertTrue(builder.build().hasCapability(NET_CAPABILITY_MMS));
+        builder.addForbiddenCapability(NET_CAPABILITY_MMS);
+        assertTrue(builder.build().hasForbiddenCapability(NET_CAPABILITY_MMS));
+        assertFalse(builder.build().hasCapability(NET_CAPABILITY_MMS));
+        builder.clearCapabilities();
+        verifyNoCapabilities(builder.build());
+    }
+
     @Test @IgnoreUpTo(Build.VERSION_CODES.Q)
     public void testTemporarilyNotMeteredCapability() {
         assertTrue(new NetworkRequest.Builder()
@@ -472,6 +493,32 @@
         assertArrayEquals(netCapabilities, nr.getCapabilities());
     }
 
+    @Test @IgnoreUpTo(VANILLA_ICE_CREAM)
+    public void testDefaultCapabilities() {
+        final NetworkRequest defaultNR = new NetworkRequest.Builder().build();
+        assertTrue(defaultNR.hasForbiddenCapability(NET_CAPABILITY_LOCAL_NETWORK));
+        assertFalse(defaultNR.hasCapability(NET_CAPABILITY_LOCAL_NETWORK));
+        assertTrue(defaultNR.hasCapability(NET_CAPABILITY_NOT_VPN));
+
+        final NetworkCapabilities emptyNC =
+                NetworkCapabilities.Builder.withoutDefaultCapabilities().build();
+        assertFalse(defaultNR.canBeSatisfiedBy(emptyNC));
+
+        // defaultNC represent the capabilities of a network agent, so they must not contain
+        // forbidden capabilities by default.
+        final NetworkCapabilities defaultNC = new NetworkCapabilities.Builder().build();
+        assertArrayEquals(new int[0], defaultNC.getForbiddenCapabilities());
+        // A default NR can be satisfied by default NC.
+        assertTrue(defaultNR.canBeSatisfiedBy(defaultNC));
+
+        // Conversely, network requests have forbidden capabilities by default to manage
+        // backward compatibility, so test that these forbidden capabilities are in place.
+        // Starting in V, NET_CAPABILITY_LOCAL_NETWORK is introduced but is not seen by
+        // default, thanks to a default forbidden capability in NetworkRequest.
+        defaultNC.addCapability(NET_CAPABILITY_LOCAL_NETWORK);
+        assertFalse(defaultNR.canBeSatisfiedBy(defaultNC));
+    }
+
     @Test
     public void testBuildRequestFromExistingRequestWithBuilder() {
         assumeTrue(TestUtils.shouldTestSApis());
diff --git a/tests/cts/net/src/android/net/cts/NetworkStatsManagerTest.java b/tests/cts/net/src/android/net/cts/NetworkStatsManagerTest.java
index 7bccbde..6a019b7 100644
--- a/tests/cts/net/src/android/net/cts/NetworkStatsManagerTest.java
+++ b/tests/cts/net/src/android/net/cts/NetworkStatsManagerTest.java
@@ -387,6 +387,7 @@
                 now = System.currentTimeMillis();
             }
         }
+        mCm.unregisterNetworkCallback(callback);
         if (callback.success) {
             mNetworkInterfacesToTest[networkTypeIndex].setMetered(callback.metered);
             mNetworkInterfacesToTest[networkTypeIndex].setRoaming(callback.roaming);
diff --git a/tests/cts/net/src/android/net/cts/NsdManagerDownstreamTetheringTest.kt b/tests/cts/net/src/android/net/cts/NsdManagerDownstreamTetheringTest.kt
index c2bb7cd..f374181 100644
--- a/tests/cts/net/src/android/net/cts/NsdManagerDownstreamTetheringTest.kt
+++ b/tests/cts/net/src/android/net/cts/NsdManagerDownstreamTetheringTest.kt
@@ -23,6 +23,7 @@
 import android.net.TetheringManager.TetheringRequest
 import android.net.nsd.NsdManager
 import android.os.Build
+import android.platform.test.annotations.AppModeFull
 import androidx.test.filters.SmallTest
 import com.android.testutils.ConnectivityModuleTest
 import com.android.testutils.DevSdkIgnoreRule
@@ -41,6 +42,7 @@
 @RunWith(DevSdkIgnoreRunner::class)
 @SmallTest
 @ConnectivityModuleTest
+@AppModeFull(reason = "WifiManager cannot be obtained in instant mode")
 @DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.S_V2)
 class NsdManagerDownstreamTetheringTest : EthernetTetheringTestBase() {
     private val nsdManager by lazy { context.getSystemService(NsdManager::class.java)!! }
diff --git a/tests/cts/net/src/android/net/cts/NsdManagerTest.kt b/tests/cts/net/src/android/net/cts/NsdManagerTest.kt
index 27bd5d3..9c44a3e 100644
--- a/tests/cts/net/src/android/net/cts/NsdManagerTest.kt
+++ b/tests/cts/net/src/android/net/cts/NsdManagerTest.kt
@@ -20,6 +20,7 @@
 import android.app.compat.CompatChanges
 import android.net.ConnectivityManager
 import android.net.ConnectivityManager.NetworkCallback
+import android.net.DnsResolver
 import android.net.InetAddresses.parseNumericAddress
 import android.net.LinkAddress
 import android.net.LinkProperties
@@ -87,6 +88,7 @@
 import com.android.testutils.TestableNetworkAgent
 import com.android.testutils.TestableNetworkAgent.CallbackEntry.OnNetworkCreated
 import com.android.testutils.TestableNetworkCallback
+import com.android.testutils.assertEmpty
 import com.android.testutils.filters.CtsNetTestCasesMaxTargetSdk30
 import com.android.testutils.filters.CtsNetTestCasesMaxTargetSdk33
 import com.android.testutils.runAsShell
@@ -424,11 +426,7 @@
 
     @Test
     fun testNsdManager_DiscoverOnNetwork() {
-        val si = NsdServiceInfo()
-        si.serviceType = serviceType
-        si.serviceName = this.serviceName
-        si.port = 12345 // Test won't try to connect so port does not matter
-
+        val si = makeTestServiceInfo()
         val registrationRecord = NsdRegistrationRecord()
         val registeredInfo = registerService(registrationRecord, si)
 
@@ -455,11 +453,7 @@
 
     @Test
     fun testNsdManager_DiscoverWithNetworkRequest() {
-        val si = NsdServiceInfo()
-        si.serviceType = serviceType
-        si.serviceName = this.serviceName
-        si.port = 12345 // Test won't try to connect so port does not matter
-
+        val si = makeTestServiceInfo()
         val handler = Handler(handlerThread.looper)
         val executor = Executor { handler.post(it) }
 
@@ -524,11 +518,6 @@
 
     @Test
     fun testNsdManager_DiscoverWithNetworkRequest_NoMatchingNetwork() {
-        val si = NsdServiceInfo()
-        si.serviceType = serviceType
-        si.serviceName = this.serviceName
-        si.port = 12345 // Test won't try to connect so port does not matter
-
         val handler = Handler(handlerThread.looper)
         val executor = Executor { handler.post(it) }
 
@@ -568,11 +557,7 @@
 
     @Test
     fun testNsdManager_ResolveOnNetwork() {
-        val si = NsdServiceInfo()
-        si.serviceType = serviceType
-        si.serviceName = this.serviceName
-        si.port = 12345 // Test won't try to connect so port does not matter
-
+        val si = makeTestServiceInfo()
         val registrationRecord = NsdRegistrationRecord()
         val registeredInfo = registerService(registrationRecord, si)
         tryTest {
@@ -610,12 +595,7 @@
 
     @Test
     fun testNsdManager_RegisterOnNetwork() {
-        val si = NsdServiceInfo()
-        si.serviceType = serviceType
-        si.serviceName = this.serviceName
-        si.network = testNetwork1.network
-        si.port = 12345 // Test won't try to connect so port does not matter
-
+        val si = makeTestServiceInfo(testNetwork1.network)
         // Register service on testNetwork1
         val registrationRecord = NsdRegistrationRecord()
         registerService(registrationRecord, si)
@@ -889,11 +869,7 @@
 
     @Test
     fun testStopServiceResolution() {
-        val si = NsdServiceInfo()
-        si.serviceType = this@NsdManagerTest.serviceType
-        si.serviceName = this@NsdManagerTest.serviceName
-        si.port = 12345 // Test won't try to connect so port does not matter
-
+        val si = makeTestServiceInfo()
         val resolveRecord = NsdResolveRecord()
         // Try to resolve an unknown service then stop it immediately.
         // Expected ResolutionStopped callback.
@@ -911,12 +887,7 @@
         val addresses = lp.addresses
         assertFalse(addresses.isEmpty())
 
-        val si = NsdServiceInfo().apply {
-            serviceType = this@NsdManagerTest.serviceType
-            serviceName = this@NsdManagerTest.serviceName
-            network = testNetwork1.network
-            port = 12345 // Test won't try to connect so port does not matter
-        }
+        val si = makeTestServiceInfo(testNetwork1.network)
 
         // Register service on the network
         val registrationRecord = NsdRegistrationRecord()
@@ -1022,11 +993,7 @@
         // This test requires shims supporting T+ APIs (NsdServiceInfo.network)
         assumeTrue(TestUtils.shouldTestTApis())
 
-        val si = NsdServiceInfo()
-        si.serviceType = serviceType
-        si.serviceName = serviceName
-        si.network = testNetwork1.network
-        si.port = 12345 // Test won't try to connect so port does not matter
+        val si = makeTestServiceInfo(testNetwork1.network)
 
         val packetReader = TapPacketReader(Handler(handlerThread.looper),
                 testNetwork1.iface.fileDescriptor.fileDescriptor, 1500 /* maxPacketSize */)
@@ -1063,11 +1030,7 @@
         // This test requires shims supporting T+ APIs (NsdServiceInfo.network)
         assumeTrue(TestUtils.shouldTestTApis())
 
-        val si = NsdServiceInfo()
-        si.serviceType = serviceType
-        si.serviceName = serviceName
-        si.network = testNetwork1.network
-        si.port = 12345 // Test won't try to connect so port does not matter
+        val si = makeTestServiceInfo(testNetwork1.network)
 
         // Register service on testNetwork1
         val registrationRecord = NsdRegistrationRecord()
@@ -1137,6 +1100,127 @@
         }
     }
 
+    // Test that even if only a PTR record is received as a reply when discovering, without the
+    // SRV, TXT, address records as recommended (but not mandated) by RFC 6763 12, the service can
+    // still be discovered.
+    @Test
+    fun testDiscoveryWithPtrOnlyResponse_ServiceIsFound() {
+        // 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)
+            }
+        } cleanup {
+            nsdManager.stopServiceDiscovery(discoveryRecord)
+            discoveryRecord.expectCallback<DiscoveryStopped>()
+        }
+    }
+
+    // Test RFC 6763 12. "Clients MUST be capable of functioning correctly with DNS servers [...]
+    // that fail to generate these additional records automatically, by issuing subsequent queries
+    // for any further record(s) they require"
+    @Test
+    fun testResolveWhenServerSendsNoAdditionalRecord() {
+        // Resolve service on testNetwork1
+        val resolveRecord = NsdResolveRecord()
+        val packetReader = TapPacketReader(Handler(handlerThread.looper),
+                testNetwork1.iface.fileDescriptor.fileDescriptor, 1500 /* maxPacketSize */)
+        packetReader.startAsyncForTest()
+        handlerThread.waitForIdle(TIMEOUT_MS)
+
+        val si = makeTestServiceInfo(testNetwork1.network)
+        nsdManager.resolveService(si, { it.run() }, resolveRecord)
+
+        val serviceFullName = "$serviceName.$serviceType.local"
+        // The query should ask for ANY, since both SRV and TXT are requested. Note legacy
+        // mdnsresponder will ask for SRV and TXT separately, and will not proceed to asking for
+        // address records without an answer for both.
+        val srvTxtQuery = packetReader.pollForQuery(serviceFullName, DnsResolver.TYPE_ANY)
+        assertNotNull(srvTxtQuery)
+
+        /*
+        Generated with:
+        scapy.raw(scapy.dns_compress(scapy.DNS(rd=0, qr=1, aa=1, qd = None, an =
+            scapy.DNSRRSRV(rrname='NsdTest123456789._nmt123456789._tcp.local',
+                rclass=0x8001, port=31234, target='testhost.local', ttl=120) /
+            scapy.DNSRR(rrname='NsdTest123456789._nmt123456789._tcp.local', type='TXT', ttl=120,
+                rdata='testkey=testvalue')
+        ))).hex()
+         */
+        val srvTxtResponsePayload = HexDump.hexStringToByteArray("000084000000000200000000104" +
+                "e7364546573743132333435363738390d5f6e6d74313233343536373839045f746370056c6f6" +
+                "3616c0000218001000000780011000000007a020874657374686f7374c030c00c00100001000" +
+                "00078001211746573746b65793d7465737476616c7565")
+        replaceServiceNameAndTypeWithTestSuffix(srvTxtResponsePayload)
+        packetReader.sendResponse(buildMdnsPacket(srvTxtResponsePayload))
+
+        val testHostname = "testhost.local"
+        val addressQuery = packetReader.pollForQuery(testHostname,
+            DnsResolver.TYPE_A, DnsResolver.TYPE_AAAA)
+        assertNotNull(addressQuery)
+
+        /*
+        Generated with:
+        scapy.raw(scapy.dns_compress(scapy.DNS(rd=0, qr=1, aa=1, qd = None, an =
+            scapy.DNSRR(rrname='testhost.local', type='A', ttl=120,
+                rdata='192.0.2.123') /
+            scapy.DNSRR(rrname='testhost.local', type='AAAA', ttl=120,
+                rdata='2001:db8::123')
+        ))).hex()
+         */
+        val addressPayload = HexDump.hexStringToByteArray("0000840000000002000000000874657374" +
+                "686f7374056c6f63616c0000010001000000780004c000027bc00c001c000100000078001020" +
+                "010db8000000000000000000000123")
+        packetReader.sendResponse(buildMdnsPacket(addressPayload))
+
+        val serviceResolved = resolveRecord.expectCallback<ServiceResolved>()
+        serviceResolved.serviceInfo.let {
+            assertEquals(serviceName, it.serviceName)
+            assertEquals(".$serviceType", it.serviceType)
+            assertEquals(testNetwork1.network, it.network)
+            assertEquals(31234, it.port)
+            assertEquals(1, it.attributes.size)
+            assertArrayEquals("testvalue".encodeToByteArray(), it.attributes["testkey"])
+        }
+        assertEquals(
+                setOf(parseNumericAddress("192.0.2.123"), parseNumericAddress("2001:db8::123")),
+                serviceResolved.serviceInfo.hostAddresses.toSet())
+    }
+
     private fun buildConflictingAnnouncement(): ByteBuffer {
         /*
         Generated with:
@@ -1148,21 +1232,37 @@
         val mdnsPayload = HexDump.hexStringToByteArray("000084000000000100000000104e736454657" +
                 "3743132333435363738390d5f6e6d74313233343536373839045f746370056c6f63616c00002" +
                 "18001000000780016000000007a0208636f6e666c696374056c6f63616c00")
-        val packetBuffer = ByteBuffer.wrap(mdnsPayload)
-        // Replace service name and types in the packet with the random ones used in the test.
+        replaceServiceNameAndTypeWithTestSuffix(mdnsPayload)
+
+        return buildMdnsPacket(mdnsPayload)
+    }
+
+    /**
+     * Replaces occurrences of "NsdTest123456789" and "_nmt123456789" in mDNS payload with the
+     * actual random name and type that are used by the test.
+     */
+    private fun replaceServiceNameAndTypeWithTestSuffix(mdnsPayload: ByteArray) {
         // Test service name and types have consistent length and are always ASCII
         val testPacketName = "NsdTest123456789".encodeToByteArray()
         val testPacketTypePrefix = "_nmt123456789".encodeToByteArray()
         val encodedServiceName = serviceName.encodeToByteArray()
         val encodedTypePrefix = serviceType.split('.')[0].encodeToByteArray()
-        assertEquals(testPacketName.size, encodedServiceName.size)
-        assertEquals(testPacketTypePrefix.size, encodedTypePrefix.size)
-        packetBuffer.position(mdnsPayload.indexOf(testPacketName))
-        packetBuffer.put(encodedServiceName)
-        packetBuffer.position(mdnsPayload.indexOf(testPacketTypePrefix))
-        packetBuffer.put(encodedTypePrefix)
 
-        return buildMdnsPacket(mdnsPayload)
+        val packetBuffer = ByteBuffer.wrap(mdnsPayload)
+        replaceAll(packetBuffer, testPacketName, encodedServiceName)
+        replaceAll(packetBuffer, testPacketTypePrefix, encodedTypePrefix)
+    }
+
+    private tailrec fun replaceAll(buffer: ByteBuffer, source: ByteArray, replacement: ByteArray) {
+        assertEquals(source.size, replacement.size)
+        val index = buffer.array().indexOf(source)
+        if (index < 0) return
+
+        val origPosition = buffer.position()
+        buffer.position(index)
+        buffer.put(replacement)
+        buffer.position(origPosition)
+        replaceAll(buffer, source, replacement)
     }
 
     private fun buildMdnsPacket(mdnsPayload: ByteArray): ByteBuffer {
diff --git a/tests/cts/net/src/android/net/cts/RateLimitTest.java b/tests/cts/net/src/android/net/cts/RateLimitTest.java
index 5c93738..36b98fc 100644
--- a/tests/cts/net/src/android/net/cts/RateLimitTest.java
+++ b/tests/cts/net/src/android/net/cts/RateLimitTest.java
@@ -36,7 +36,6 @@
 import android.icu.text.MessageFormat;
 import android.net.ConnectivityManager;
 import android.net.ConnectivitySettingsManager;
-import android.net.ConnectivityThread;
 import android.net.InetAddresses;
 import android.net.IpPrefix;
 import android.net.LinkAddress;
@@ -190,19 +189,7 @@
             // whatever happens, don't leave the device in rate limited state.
             ConnectivitySettingsManager.setIngressRateLimitInBytesPerSecond(mContext, -1);
         }
-        if (mSocket == null) {
-            // HACK(b/272147742): dump ConnectivityThread if test initialization failed.
-            final StackTraceElement[] elements = ConnectivityThread.get().getStackTrace();
-            final StringBuilder sb = new StringBuilder();
-            // Skip first element as it includes the invocation of getStackTrace()
-            for (int i = 1; i < elements.length; i++) {
-                sb.append(elements[i]);
-                sb.append("\n");
-            }
-            Log.e(TAG, sb.toString());
-        } else {
-            mSocket.close();
-        }
+        if (mSocket != null) mSocket.close();
         if (mNetworkAgent != null) mNetworkAgent.unregister();
         if (mTunInterface != null) mTunInterface.getFileDescriptor().close();
         if (mCm != null) mCm.unregisterNetworkCallback(mNetworkCallback);
diff --git a/tests/cts/net/src/android/net/cts/VpnServiceTest.java b/tests/cts/net/src/android/net/cts/VpnServiceTest.java
index 5c7b5ca..f343e83 100644
--- a/tests/cts/net/src/android/net/cts/VpnServiceTest.java
+++ b/tests/cts/net/src/android/net/cts/VpnServiceTest.java
@@ -15,12 +15,28 @@
  */
 package android.net.cts;
 
+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.Assume.assumeFalse;
+
+import android.content.Context;
 import android.content.Intent;
+import android.content.pm.PackageManager;
 import android.net.VpnService;
 import android.os.ParcelFileDescriptor;
 import android.platform.test.annotations.AppModeFull;
 import android.test.AndroidTestCase;
 
+import androidx.test.InstrumentationRegistry;
+import androidx.test.runner.AndroidJUnit4;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
 import java.io.File;
 import java.net.DatagramSocket;
 import java.net.Socket;
@@ -30,12 +46,21 @@
  * blocks us from writing tests for positive cases. For now we only test for
  * negative cases, and we will try to cover the rest in the future.
  */
-public class VpnServiceTest extends AndroidTestCase {
+@RunWith(AndroidJUnit4.class)
+public class VpnServiceTest {
 
     private static final String TAG = VpnServiceTest.class.getSimpleName();
 
+    private final Context mContext = InstrumentationRegistry.getContext();
     private VpnService mVpnService = new VpnService();
 
+    @Before
+    public void setUp() {
+        assumeFalse("Skipping test because watches don't support VPN",
+            mContext.getPackageManager().hasSystemFeature(PackageManager.FEATURE_WATCH));
+    }
+
+    @Test
     @AppModeFull(reason = "PackageManager#queryIntentActivities cannot access in instant app mode")
     public void testPrepare() throws Exception {
         // Should never return null since we are not prepared.
@@ -47,6 +72,7 @@
         assertEquals(1, count);
     }
 
+    @Test
     @AppModeFull(reason = "establish() requires prepare(), which requires PackageManager access")
     public void testEstablish() throws Exception {
         ParcelFileDescriptor descriptor = null;
@@ -63,6 +89,7 @@
         }
     }
 
+    @Test
     @AppModeFull(reason = "Protecting sockets requires prepare(), which requires PackageManager")
     public void testProtect_DatagramSocket() throws Exception {
         DatagramSocket socket = new DatagramSocket();
@@ -78,6 +105,7 @@
         }
     }
 
+    @Test
     @AppModeFull(reason = "Protecting sockets requires prepare(), which requires PackageManager")
     public void testProtect_Socket() throws Exception {
         Socket socket = new Socket();
@@ -93,6 +121,7 @@
         }
     }
 
+    @Test
     @AppModeFull(reason = "Protecting sockets requires prepare(), which requires PackageManager")
     public void testProtect_int() throws Exception {
         DatagramSocket socket = new DatagramSocket();
@@ -114,6 +143,7 @@
         }
     }
 
+    @Test
     public void testTunDevice() throws Exception {
         File file = new File("/dev/tun");
         assertTrue(file.exists());
diff --git a/tests/integration/Android.bp b/tests/integration/Android.bp
index 12919ae..f705e34 100644
--- a/tests/integration/Android.bp
+++ b/tests/integration/Android.bp
@@ -45,6 +45,7 @@
         // order-dependent setup.
         "NetworkStackApiStableLib",
         "androidx.test.ext.junit",
+        "compatibility-device-util-axt",
         "frameworks-net-integration-testutils",
         "kotlin-reflect",
         "mockito-target-extended-minus-junit4",
diff --git a/tests/integration/AndroidManifest.xml b/tests/integration/AndroidManifest.xml
index 50f02d3..cea83c7 100644
--- a/tests/integration/AndroidManifest.xml
+++ b/tests/integration/AndroidManifest.xml
@@ -40,6 +40,8 @@
     <uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
     <!-- Querying the resources package -->
     <uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"/>
+    <!-- Register UidFrozenStateChangedCallback -->
+    <uses-permission android:name="android.permission.PACKAGE_USAGE_STATS"/>
     <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 e264b55..9b082a4 100644
--- a/tests/integration/src/com/android/server/net/integrationtests/ConnectivityServiceIntegrationTest.kt
+++ b/tests/integration/src/com/android/server/net/integrationtests/ConnectivityServiceIntegrationTest.kt
@@ -40,11 +40,14 @@
 import android.os.IBinder
 import android.os.SystemConfigManager
 import android.os.UserHandle
+import android.os.VintfRuntimeInfo
 import android.testing.TestableContext
 import android.util.Log
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.platform.app.InstrumentationRegistry
+import com.android.compatibility.common.util.SystemUtil
 import com.android.connectivity.resources.R
+import com.android.net.module.util.BpfUtils
 import com.android.server.BpfNetMaps
 import com.android.server.ConnectivityService
 import com.android.server.NetworkAgentWrapper
@@ -53,6 +56,7 @@
 import com.android.server.connectivity.MockableSystemProperties
 import com.android.server.connectivity.MultinetworkPolicyTracker
 import com.android.server.connectivity.ProxyTracker
+import com.android.testutils.DeviceInfoUtils
 import com.android.testutils.RecorderCallback.CallbackEntry.LinkPropertiesChanged
 import com.android.testutils.TestableNetworkCallback
 import kotlin.test.assertEquals
@@ -60,6 +64,7 @@
 import kotlin.test.assertTrue
 import kotlin.test.fail
 import org.junit.After
+import org.junit.Assume
 import org.junit.Before
 import org.junit.BeforeClass
 import org.junit.Test
@@ -302,4 +307,25 @@
                     !it.hasCapability(NET_CAPABILITY_VALIDATED)
         }
     }
+
+    private fun isBpfGetCgroupProgramIdSupportedByKernel(): Boolean {
+        val kVersionString = VintfRuntimeInfo.getKernelVersion()
+        return DeviceInfoUtils.compareMajorMinorVersion(kVersionString, "4.19") >= 0
+    }
+
+    @Test
+    fun testBpfProgramAttachStatus() {
+        Assume.assumeTrue(isBpfGetCgroupProgramIdSupportedByKernel())
+
+        listOf(
+                BpfUtils.BPF_CGROUP_INET_INGRESS,
+                BpfUtils.BPF_CGROUP_INET_EGRESS,
+                BpfUtils.BPF_CGROUP_INET_SOCK_CREATE
+        ).forEach {
+            val ret = SystemUtil.runShellCommand(InstrumentationRegistry.getInstrumentation(),
+                    "cmd connectivity bpf-get-cgroup-program-id $it").trim()
+
+            assertTrue(Integer.parseInt(ret) > 0, "Unexpected output $ret for type $it")
+        }
+    }
 }
diff --git a/tests/integration/util/com/android/server/NetworkAgentWrapper.java b/tests/integration/util/com/android/server/NetworkAgentWrapper.java
index edd201d..ec09f9e 100644
--- a/tests/integration/util/com/android/server/NetworkAgentWrapper.java
+++ b/tests/integration/util/com/android/server/NetworkAgentWrapper.java
@@ -19,6 +19,7 @@
 import static android.net.NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_NOT_VCN_MANAGED;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_NOT_VPN;
+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_TEST;
@@ -123,6 +124,10 @@
         mNetworkCapabilities.addCapability(NET_CAPABILITY_NOT_VCN_MANAGED);
         mNetworkCapabilities.addTransportType(transport);
         switch (transport) {
+            case TRANSPORT_BLUETOOTH:
+                // Score for Wear companion proxy network; not BLUETOOTH tethering.
+                mScore = new NetworkScore.Builder().setLegacyInt(100).build();
+                break;
             case TRANSPORT_ETHERNET:
                 mScore = new NetworkScore.Builder().setLegacyInt(70).build();
                 break;
diff --git a/tests/mts/bpf_existence_test.cpp b/tests/mts/bpf_existence_test.cpp
index 15263cc..51a4eca 100644
--- a/tests/mts/bpf_existence_test.cpp
+++ b/tests/mts/bpf_existence_test.cpp
@@ -40,6 +40,7 @@
 #define TETHERING "/sys/fs/bpf/tethering/"
 #define PRIVATE "/sys/fs/bpf/net_private/"
 #define SHARED "/sys/fs/bpf/net_shared/"
+#define NETD_RO "/sys/fs/bpf/netd_readonly/"
 #define NETD "/sys/fs/bpf/netd_shared/"
 
 class BpfExistenceTest : public ::testing::Test {
@@ -93,6 +94,7 @@
     NETD "map_netd_app_uid_stats_map",
     NETD "map_netd_configuration_map",
     NETD "map_netd_cookie_tag_map",
+    NETD "map_netd_data_saver_enabled_map",
     NETD "map_netd_iface_index_name_map",
     NETD "map_netd_iface_stats_map",
     NETD "map_netd_ingress_discard_map",
@@ -119,9 +121,9 @@
 };
 
 // Provided by *current* mainline module for T+ devices with 5.4+ kernels
-static const set<string> MAINLINE_FOR_T_5_4_PLUS = {
-    SHARED "prog_block_bind4_block_port",
-    SHARED "prog_block_bind6_block_port",
+static const set<string> MAINLINE_FOR_T_4_19_PLUS = {
+    NETD_RO "prog_block_bind4_block_port",
+    NETD_RO "prog_block_bind6_block_port",
 };
 
 // Provided by *current* mainline module for T+ devices with 5.15+ kernels
@@ -129,6 +131,16 @@
     SHARED "prog_dscpPolicy_schedcls_set_dscp_ether",
 };
 
+// Provided by *current* mainline module for U+ devices
+static const set<string> MAINLINE_FOR_U_PLUS = {
+    NETD "map_netd_packet_trace_enabled_map",
+};
+
+// Provided by *current* mainline module for U+ devices with 5.10+ kernels
+static const set<string> MAINLINE_FOR_U_5_10_PLUS = {
+    NETD "map_netd_packet_trace_ringbuf",
+};
+
 static void addAll(set<string>& a, const set<string>& b) {
     a.insert(b.begin(), b.end());
 }
@@ -166,11 +178,13 @@
     // T still only requires Linux Kernel 4.9+.
     DO_EXPECT(IsAtLeastT(), MAINLINE_FOR_T_PLUS);
     DO_EXPECT(IsAtLeastT() && isAtLeastKernelVersion(4, 14, 0), MAINLINE_FOR_T_4_14_PLUS);
-    DO_EXPECT(IsAtLeastT() && isAtLeastKernelVersion(5, 4, 0), MAINLINE_FOR_T_5_4_PLUS);
+    DO_EXPECT(IsAtLeastT() && isAtLeastKernelVersion(4, 19, 0), MAINLINE_FOR_T_4_19_PLUS);
     DO_EXPECT(IsAtLeastT() && isAtLeastKernelVersion(5, 15, 0), MAINLINE_FOR_T_5_15_PLUS);
 
     // U requires Linux Kernel 4.14+, but nothing (as yet) added or removed in U.
     if (IsAtLeastU()) ASSERT_TRUE(isAtLeastKernelVersion(4, 14, 0));
+    DO_EXPECT(IsAtLeastU(), MAINLINE_FOR_U_PLUS);
+    DO_EXPECT(IsAtLeastU() && isAtLeastKernelVersion(5, 10, 0), MAINLINE_FOR_U_5_10_PLUS);
 
     // V requires Linux Kernel 4.19+, but nothing (as yet) added or removed in V.
     if (IsAtLeastV()) ASSERT_TRUE(isAtLeastKernelVersion(4, 19, 0));
diff --git a/tests/native/utilities/firewall.cpp b/tests/native/utilities/firewall.cpp
index e4669cb..22f83e8 100644
--- a/tests/native/utilities/firewall.cpp
+++ b/tests/native/utilities/firewall.cpp
@@ -27,6 +27,12 @@
 
     result = mUidOwnerMap.init(UID_OWNER_MAP_PATH);
     EXPECT_RESULT_OK(result) << "init mUidOwnerMap failed";
+
+    // Do not check whether DATA_SAVER_ENABLED_MAP_PATH init succeeded or failed since the map is
+    // defined in tethering module, but the user of this class may be in other modules. For example,
+    // DNS resolver tests statically link to this class. But when running MTS, the test infra
+    // installs only DNS resolver module without installing tethering module together.
+    mDataSaverEnabledMap.init(DATA_SAVER_ENABLED_MAP_PATH);
 }
 
 Firewall* Firewall::getInstance() {
@@ -116,3 +122,28 @@
     }
     return {};
 }
+
+Result<bool> Firewall::getDataSaverSetting() {
+    std::lock_guard guard(mMutex);
+    if (!mDataSaverEnabledMap.isValid()) {
+        return Errorf("init mDataSaverEnabledMap failed");
+    }
+
+    auto dataSaverSetting = mDataSaverEnabledMap.readValue(DATA_SAVER_ENABLED_KEY);
+    if (!dataSaverSetting.ok()) {
+        return Errorf("Cannot read the data saver setting: {}", dataSaverSetting.error().message());
+    }
+    return dataSaverSetting;
+}
+
+Result<void> Firewall::setDataSaver(bool enabled) {
+    std::lock_guard guard(mMutex);
+    if (!mDataSaverEnabledMap.isValid()) {
+        return Errorf("init mDataSaverEnabledMap failed");
+    }
+
+    auto res = mDataSaverEnabledMap.writeValue(DATA_SAVER_ENABLED_KEY, enabled, BPF_EXIST);
+    if (!res.ok()) return Errorf("Failed to set data saver: {}", res.error().message());
+
+    return {};
+}
diff --git a/tests/native/utilities/firewall.h b/tests/native/utilities/firewall.h
index 1e7e987..b3d69bf 100644
--- a/tests/native/utilities/firewall.h
+++ b/tests/native/utilities/firewall.h
@@ -33,9 +33,11 @@
     Result<void> removeRule(uint32_t uid, UidOwnerMatchType match) EXCLUDES(mMutex);
     Result<void> addUidInterfaceRules(const std::string& ifName, const std::vector<int32_t>& uids);
     Result<void> removeUidInterfaceRules(const std::vector<int32_t>& uids);
-
+    Result<bool> getDataSaverSetting();
+    Result<void> setDataSaver(bool enabled);
   private:
     BpfMap<uint32_t, uint32_t> mConfigurationMap GUARDED_BY(mMutex);
     BpfMap<uint32_t, UidOwnerValue> mUidOwnerMap GUARDED_BY(mMutex);
+    BpfMap<uint32_t, bool> mDataSaverEnabledMap GUARDED_BY(mMutex);
     std::mutex mMutex;
 };
diff --git a/tests/unit/AndroidManifest.xml b/tests/unit/AndroidManifest.xml
index 5d4bdf7..2853f31 100644
--- a/tests/unit/AndroidManifest.xml
+++ b/tests/unit/AndroidManifest.xml
@@ -49,6 +49,9 @@
     <uses-permission android:name="android.permission.NETWORK_FACTORY" />
     <uses-permission android:name="android.permission.NETWORK_STATS_PROVIDER" />
     <uses-permission android:name="android.permission.CONTROL_OEM_PAID_NETWORK_PREFERENCE" />
+    <!-- Workaround for flakes where the launcher package is not found despite the <queries> tag
+         below (b/286550950). -->
+    <uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
 
     <!-- Declare the intent that the test intends to query. This is necessary for
          UiDevice.getLauncherPackageName which is used in NetworkNotificationManagerTest
diff --git a/tests/unit/java/android/net/BpfNetMapsReaderTest.kt b/tests/unit/java/android/net/BpfNetMapsReaderTest.kt
new file mode 100644
index 0000000..258e422
--- /dev/null
+++ b/tests/unit/java/android/net/BpfNetMapsReaderTest.kt
@@ -0,0 +1,202 @@
+/*
+ * 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 android.net.BpfNetMapsConstants.DOZABLE_MATCH
+import android.net.BpfNetMapsConstants.HAPPY_BOX_MATCH
+import android.net.BpfNetMapsConstants.PENALTY_BOX_MATCH
+import android.net.BpfNetMapsConstants.STANDBY_MATCH
+import android.net.BpfNetMapsConstants.UID_RULES_CONFIGURATION_KEY
+import android.net.BpfNetMapsUtils.getMatchByFirewallChain
+import android.os.Build.VERSION_CODES
+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.testutils.DevSdkIgnoreRule.IgnoreUpTo
+import com.android.testutils.DevSdkIgnoreRunner
+import com.android.testutils.TestBpfMap
+import java.lang.reflect.Modifier
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+
+private const val TEST_UID1 = 1234
+private const val TEST_UID2 = TEST_UID1 + 1
+private const val TEST_UID3 = TEST_UID2 + 1
+private const val NO_IIF = 0
+
+// pre-T devices does not support Bpf.
+@RunWith(DevSdkIgnoreRunner::class)
+@IgnoreUpTo(VERSION_CODES.S_V2)
+class BpfNetMapsReaderTest {
+    private val testConfigurationMap: IBpfMap<S32, U32> = TestBpfMap()
+    private val testUidOwnerMap: IBpfMap<S32, UidOwnerValue> = TestBpfMap()
+    private val bpfNetMapsReader = BpfNetMapsReader(
+        TestDependencies(testConfigurationMap, testUidOwnerMap))
+
+    class TestDependencies(
+        private val configMap: IBpfMap<S32, U32>,
+        private val uidOwnerMap: IBpfMap<S32, UidOwnerValue>
+    ) : BpfNetMapsReader.Dependencies() {
+        override fun getConfigurationMap() = configMap
+        override fun getUidOwnerMap() = uidOwnerMap
+    }
+
+    private fun doTestIsChainEnabled(chain: Int) {
+        testConfigurationMap.updateEntry(
+            UID_RULES_CONFIGURATION_KEY,
+            U32(getMatchByFirewallChain(chain))
+        )
+        assertTrue(bpfNetMapsReader.isChainEnabled(chain))
+        testConfigurationMap.updateEntry(UID_RULES_CONFIGURATION_KEY, U32(0))
+        assertFalse(bpfNetMapsReader.isChainEnabled(chain))
+    }
+
+    @Test
+    @Throws(Exception::class)
+    fun testIsChainEnabled() {
+        doTestIsChainEnabled(ConnectivityManager.FIREWALL_CHAIN_DOZABLE)
+        doTestIsChainEnabled(ConnectivityManager.FIREWALL_CHAIN_STANDBY)
+        doTestIsChainEnabled(ConnectivityManager.FIREWALL_CHAIN_POWERSAVE)
+        doTestIsChainEnabled(ConnectivityManager.FIREWALL_CHAIN_RESTRICTED)
+        doTestIsChainEnabled(ConnectivityManager.FIREWALL_CHAIN_LOW_POWER_STANDBY)
+    }
+
+    @Test
+    fun testFirewallChainList() {
+        // Verify that when a firewall chain constant is added, it should also be included in
+        // firewall chain list.
+        val declaredChains = ConnectivityManager::class.java.declaredFields.filter {
+            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)
+        declaredChains.forEach {
+            assertTrue(BpfNetMapsConstants.ALLOW_CHAINS.contains(it.get(null)) ||
+                    BpfNetMapsConstants.DENY_CHAINS.contains(it.get(null)))
+        }
+    }
+
+    private fun mockChainEnabled(chain: Int, enabled: Boolean) {
+        val config = testConfigurationMap.getValue(UID_RULES_CONFIGURATION_KEY).`val`
+        val newConfig = if (enabled) {
+            config or getMatchByFirewallChain(chain)
+        } else {
+            config and getMatchByFirewallChain(chain).inv()
+        }
+        testConfigurationMap.updateEntry(UID_RULES_CONFIGURATION_KEY, U32(newConfig))
+    }
+
+    fun isUidNetworkingBlocked(uid: Int, metered: Boolean = false, dataSaver: Boolean = false) =
+            bpfNetMapsReader.isUidNetworkingBlocked(uid, metered, dataSaver)
+
+    @Test
+    fun testIsUidNetworkingBlockedByFirewallChains_allowChain() {
+        // With everything disabled by default, verify the return value is false.
+        testConfigurationMap.updateEntry(UID_RULES_CONFIGURATION_KEY, U32(0))
+        assertFalse(isUidNetworkingBlocked(TEST_UID1))
+
+        // Enable dozable chain but does not provide allowed list. Verify the network is blocked
+        // for all uids.
+        mockChainEnabled(ConnectivityManager.FIREWALL_CHAIN_DOZABLE, true)
+        assertTrue(isUidNetworkingBlocked(TEST_UID1))
+        assertTrue(isUidNetworkingBlocked(TEST_UID2))
+
+        // Add uid1 to dozable allowed list. Verify the network is not blocked for uid1, while
+        // uid2 is blocked.
+        testUidOwnerMap.updateEntry(S32(TEST_UID1), UidOwnerValue(NO_IIF, DOZABLE_MATCH))
+        assertFalse(isUidNetworkingBlocked(TEST_UID1))
+        assertTrue(isUidNetworkingBlocked(TEST_UID2))
+    }
+
+    @Test
+    fun testIsUidNetworkingBlockedByFirewallChains_denyChain() {
+        // 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))
+        mockChainEnabled(ConnectivityManager.FIREWALL_CHAIN_STANDBY, true)
+        assertFalse(isUidNetworkingBlocked(TEST_UID1))
+        assertFalse(isUidNetworkingBlocked(TEST_UID2))
+
+        // Add uid1 to standby allowed list. Verify the network is blocked for uid1, while
+        // uid2 is not blocked.
+        testUidOwnerMap.updateEntry(S32(TEST_UID1), UidOwnerValue(NO_IIF, STANDBY_MATCH))
+        assertTrue(isUidNetworkingBlocked(TEST_UID1))
+        assertFalse(isUidNetworkingBlocked(TEST_UID2))
+    }
+
+    @Test
+    fun testIsUidNetworkingBlockedByFirewallChains_blockedWithAllowed() {
+        // Uids blocked by powersave chain but allowed by standby chain, verify the blocking
+        // takes higher priority.
+        testConfigurationMap.updateEntry(UID_RULES_CONFIGURATION_KEY, U32(0))
+        mockChainEnabled(ConnectivityManager.FIREWALL_CHAIN_POWERSAVE, true)
+        mockChainEnabled(ConnectivityManager.FIREWALL_CHAIN_STANDBY, true)
+        assertTrue(isUidNetworkingBlocked(TEST_UID1))
+    }
+
+    @IgnoreUpTo(VERSION_CODES.S_V2)
+    @Test
+    fun testIsUidNetworkingBlockedByDataSaver() {
+        // 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))
+
+        // Add uid1 to penalty box, verify the network is blocked for uid1, while uid2 is not
+        // affected.
+        testUidOwnerMap.updateEntry(S32(TEST_UID1), UidOwnerValue(NO_IIF, PENALTY_BOX_MATCH))
+        assertTrue(isUidNetworkingBlocked(TEST_UID1, metered = true))
+        assertFalse(isUidNetworkingBlocked(TEST_UID2, metered = true))
+
+        // Enable data saver, verify the network is blocked for uid1, uid2, but uid3 in happy box
+        // is not affected.
+        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))
+
+        // Add uid1 to happy box as well, verify nothing is changed because penalty box has higher
+        // priority.
+        testUidOwnerMap.updateEntry(
+            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))
+
+        // 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))
+
+        // Disable doze mode and data saver, only uid1 which is in penalty box is blocked.
+        mockChainEnabled(ConnectivityManager.FIREWALL_CHAIN_DOZABLE, false)
+        assertTrue(isUidNetworkingBlocked(TEST_UID1, metered = true))
+        assertFalse(isUidNetworkingBlocked(TEST_UID2, metered = true))
+        assertFalse(isUidNetworkingBlocked(TEST_UID3, metered = true))
+
+        // Make the network non-metered, nothing is blocked.
+        assertFalse(isUidNetworkingBlocked(TEST_UID1))
+        assertFalse(isUidNetworkingBlocked(TEST_UID2))
+        assertFalse(isUidNetworkingBlocked(TEST_UID3))
+    }
+}
diff --git a/tests/unit/java/android/net/ConnectivityManagerTest.java b/tests/unit/java/android/net/ConnectivityManagerTest.java
index 45a9dbc..b8c5447 100644
--- a/tests/unit/java/android/net/ConnectivityManagerTest.java
+++ b/tests/unit/java/android/net/ConnectivityManagerTest.java
@@ -16,6 +16,13 @@
 
 package android.net;
 
+import static android.content.Context.RECEIVER_NOT_EXPORTED;
+import static android.content.pm.ApplicationInfo.FLAG_PERSISTENT;
+import static android.content.pm.ApplicationInfo.FLAG_SYSTEM;
+import static android.net.ConnectivityManager.ACTION_RESTRICT_BACKGROUND_CHANGED;
+import static android.net.ConnectivityManager.RESTRICT_BACKGROUND_STATUS_DISABLED;
+import static android.net.ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED;
+import static android.net.ConnectivityManager.RESTRICT_BACKGROUND_STATUS_WHITELISTED;
 import static android.net.ConnectivityManager.TYPE_NONE;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_CBS;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_DUN;
@@ -39,6 +46,7 @@
 
 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;
@@ -51,6 +59,7 @@
 import static org.mockito.Mockito.after;
 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;
 import static org.mockito.Mockito.reset;
@@ -61,7 +70,10 @@
 
 import android.app.PendingIntent;
 import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
 import android.content.pm.ApplicationInfo;
+import android.net.ConnectivityManager.DataSaverStatusTracker;
 import android.net.ConnectivityManager.NetworkCallback;
 import android.os.Build.VERSION_CODES;
 import android.os.Bundle;
@@ -95,6 +107,7 @@
 
     @Mock Context mCtx;
     @Mock IConnectivityManager mService;
+    @Mock NetworkPolicyManager mNpm;
 
     @Before
     public void setUp() {
@@ -510,4 +523,54 @@
         assertNull("ConnectivityManager weak reference still not null after " + attempts
                     + " attempts", ref.get());
     }
+
+    @Test
+    public void testDataSaverStatusTracker() {
+        mockService(NetworkPolicyManager.class, Context.NETWORK_POLICY_SERVICE, mNpm);
+        // Mock proper application info.
+        doReturn(mCtx).when(mCtx).getApplicationContext();
+        final ApplicationInfo mockAppInfo = new ApplicationInfo();
+        mockAppInfo.flags = FLAG_PERSISTENT | FLAG_SYSTEM;
+        doReturn(mockAppInfo).when(mCtx).getApplicationInfo();
+        // Enable data saver.
+        doReturn(RESTRICT_BACKGROUND_STATUS_ENABLED).when(mNpm)
+                .getRestrictBackgroundStatus(anyInt());
+
+        final DataSaverStatusTracker tracker = new DataSaverStatusTracker(mCtx);
+        // Verify the data saver status is correct right after initialization.
+        assertTrue(tracker.getDataSaverEnabled());
+
+        // Verify the tracker register receiver with expected intent filter.
+        final ArgumentCaptor<IntentFilter> intentFilterCaptor =
+                ArgumentCaptor.forClass(IntentFilter.class);
+        verify(mCtx).registerReceiver(
+                any(), intentFilterCaptor.capture(), eq(RECEIVER_NOT_EXPORTED));
+        assertEquals(ACTION_RESTRICT_BACKGROUND_CHANGED,
+                intentFilterCaptor.getValue().getAction(0));
+
+        // Mock data saver status changed event and verify the tracker tracks the
+        // status accordingly.
+        doReturn(RESTRICT_BACKGROUND_STATUS_DISABLED).when(mNpm)
+                .getRestrictBackgroundStatus(anyInt());
+        tracker.onReceive(mCtx, new Intent(ACTION_RESTRICT_BACKGROUND_CHANGED));
+        assertFalse(tracker.getDataSaverEnabled());
+
+        doReturn(RESTRICT_BACKGROUND_STATUS_WHITELISTED).when(mNpm)
+                .getRestrictBackgroundStatus(anyInt());
+        tracker.onReceive(mCtx, new Intent(ACTION_RESTRICT_BACKGROUND_CHANGED));
+        assertTrue(tracker.getDataSaverEnabled());
+    }
+
+    private <T> void mockService(Class<T> clazz, String name, T service) {
+        doReturn(service).when(mCtx).getSystemService(name);
+        doReturn(name).when(mCtx).getSystemServiceName(clazz);
+
+        // If the test suite uses the inline mock maker library, such as for coverage tests,
+        // then the final version of getSystemService must also be mocked, as the real
+        // method will not be called by the test and null object is returned since no mock.
+        // Otherwise, mocking a final method will fail the test.
+        if (mCtx.getSystemService(clazz) == null) {
+            doReturn(service).when(mCtx).getSystemService(clazz);
+        }
+    }
 }
diff --git a/tests/unit/java/android/net/NetworkStatsHistoryTest.java b/tests/unit/java/android/net/NetworkStatsHistoryTest.java
index 2170882..1e1fd35 100644
--- a/tests/unit/java/android/net/NetworkStatsHistoryTest.java
+++ b/tests/unit/java/android/net/NetworkStatsHistoryTest.java
@@ -54,6 +54,7 @@
 import com.android.frameworks.tests.net.R;
 import com.android.testutils.DevSdkIgnoreRule;
 import com.android.testutils.DevSdkIgnoreRunner;
+import com.android.testutils.SkipPresubmit;
 
 import org.junit.After;
 import org.junit.Test;
@@ -343,6 +344,7 @@
 
     }
 
+    @SkipPresubmit(reason = "Flaky: b/302325928; add to presubmit after fixing")
     @Test
     public void testFuzzing() throws Exception {
         try {
diff --git a/tests/unit/java/android/net/VpnManagerTest.java b/tests/unit/java/android/net/VpnManagerTest.java
index 532081a..2ab4e45 100644
--- a/tests/unit/java/android/net/VpnManagerTest.java
+++ b/tests/unit/java/android/net/VpnManagerTest.java
@@ -19,6 +19,7 @@
 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;
@@ -27,11 +28,13 @@
 
 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;
@@ -47,6 +50,7 @@
 @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";
@@ -66,6 +70,9 @@
 
     @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);
     }
diff --git a/tests/unit/java/com/android/server/BpfNetMapsTest.java b/tests/unit/java/com/android/server/BpfNetMapsTest.java
index 5f280c6..1e08fcc 100644
--- a/tests/unit/java/com/android/server/BpfNetMapsTest.java
+++ b/tests/unit/java/com/android/server/BpfNetMapsTest.java
@@ -16,7 +16,12 @@
 
 package com.android.server;
 
+import static android.net.BpfNetMapsConstants.ALLOW_CHAINS;
 import static android.net.BpfNetMapsConstants.CURRENT_STATS_MAP_CONFIGURATION_KEY;
+import static android.net.BpfNetMapsConstants.DATA_SAVER_ENABLED_KEY;
+import static android.net.BpfNetMapsConstants.DATA_SAVER_DISABLED;
+import static android.net.BpfNetMapsConstants.DATA_SAVER_ENABLED;
+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.IIF_MATCH;
@@ -66,6 +71,8 @@
 import android.content.Context;
 import android.net.BpfNetMapsUtils;
 import android.net.INetd;
+import android.net.InetAddresses;
+import android.net.UidOwnerValue;
 import android.os.Build;
 import android.os.ServiceSpecificException;
 import android.system.ErrnoException;
@@ -81,6 +88,8 @@
 import com.android.net.module.util.Struct.U8;
 import com.android.net.module.util.bpf.CookieTagMapKey;
 import com.android.net.module.util.bpf.CookieTagMapValue;
+import com.android.net.module.util.bpf.IngressDiscardKey;
+import com.android.net.module.util.bpf.IngressDiscardValue;
 import com.android.testutils.DevSdkIgnoreRule;
 import com.android.testutils.DevSdkIgnoreRule.IgnoreAfter;
 import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo;
@@ -96,6 +105,8 @@
 
 import java.io.FileDescriptor;
 import java.io.StringWriter;
+import java.net.Inet4Address;
+import java.net.Inet6Address;
 import java.util.ArrayList;
 import java.util.List;
 
@@ -114,21 +125,21 @@
     private static final int TEST_IF_INDEX = 7;
     private static final int NO_IIF = 0;
     private static final int NULL_IIF = 0;
+    private static final Inet4Address TEST_V4_ADDRESS =
+            (Inet4Address) InetAddresses.parseNumericAddress("192.0.2.1");
+    private static final Inet6Address TEST_V6_ADDRESS =
+            (Inet6Address) InetAddresses.parseNumericAddress("2001:db8::1");
     private static final String CHAINNAME = "fw_dozable";
-    private static final List<Integer> FIREWALL_CHAINS = List.of(
-            FIREWALL_CHAIN_DOZABLE,
-            FIREWALL_CHAIN_STANDBY,
-            FIREWALL_CHAIN_POWERSAVE,
-            FIREWALL_CHAIN_RESTRICTED,
-            FIREWALL_CHAIN_LOW_POWER_STANDBY,
-            FIREWALL_CHAIN_OEM_DENY_1,
-            FIREWALL_CHAIN_OEM_DENY_2,
-            FIREWALL_CHAIN_OEM_DENY_3
-    );
 
     private static final long STATS_SELECT_MAP_A = 0;
     private static final long STATS_SELECT_MAP_B = 1;
 
+    private static final List<Integer> FIREWALL_CHAINS = new ArrayList<>();
+    static {
+        FIREWALL_CHAINS.addAll(ALLOW_CHAINS);
+        FIREWALL_CHAINS.addAll(DENY_CHAINS);
+    }
+
     private BpfNetMaps mBpfNetMaps;
 
     @Mock INetd mNetd;
@@ -140,11 +151,15 @@
     private final IBpfMap<S32, U8> mUidPermissionMap = new TestBpfMap<>(S32.class, U8.class);
     private final IBpfMap<CookieTagMapKey, CookieTagMapValue> mCookieTagMap =
             spy(new TestBpfMap<>(CookieTagMapKey.class, CookieTagMapValue.class));
+    private final IBpfMap<S32, U8> mDataSaverEnabledMap = new TestBpfMap<>(S32.class, U8.class);
+    private final IBpfMap<IngressDiscardKey, IngressDiscardValue> mIngressDiscardMap =
+            new TestBpfMap<>(IngressDiscardKey.class, IngressDiscardValue.class);
 
     @Before
     public void setUp() throws Exception {
         MockitoAnnotations.initMocks(this);
         doReturn(TEST_IF_INDEX).when(mDeps).getIfIndex(TEST_IF_NAME);
+        doReturn(TEST_IF_NAME).when(mDeps).getIfName(TEST_IF_INDEX);
         doReturn(0).when(mDeps).synchronizeKernelRCU();
         BpfNetMaps.setEnableJavaBpfMapForTest(true /* enable */);
         BpfNetMaps.setConfigurationMapForTest(mConfigurationMap);
@@ -154,6 +169,9 @@
         BpfNetMaps.setUidOwnerMapForTest(mUidOwnerMap);
         BpfNetMaps.setUidPermissionMapForTest(mUidPermissionMap);
         BpfNetMaps.setCookieTagMapForTest(mCookieTagMap);
+        BpfNetMaps.setDataSaverEnabledMapForTest(mDataSaverEnabledMap);
+        mDataSaverEnabledMap.updateEntry(DATA_SAVER_ENABLED_KEY, new U8(DATA_SAVER_DISABLED));
+        BpfNetMaps.setIngressDiscardMapForTest(mIngressDiscardMap);
         mBpfNetMaps = new BpfNetMaps(mContext, mNetd, mDeps);
     }
 
@@ -610,7 +628,7 @@
         mUidOwnerMap.updateEntry(new S32(TEST_UID), new UidOwnerValue(TEST_IF_INDEX, IIF_MATCH));
 
         for (final int chain: testChains) {
-            final int ruleToAddMatch = mBpfNetMaps.isFirewallAllowList(chain)
+            final int ruleToAddMatch = BpfNetMapsUtils.isFirewallAllowList(chain)
                     ? FIREWALL_RULE_ALLOW : FIREWALL_RULE_DENY;
             mBpfNetMaps.setUidRule(chain, TEST_UID, ruleToAddMatch);
         }
@@ -618,7 +636,7 @@
         checkUidOwnerValue(TEST_UID, TEST_IF_INDEX, IIF_MATCH | getMatch(testChains));
 
         for (final int chain: testChains) {
-            final int ruleToRemoveMatch = mBpfNetMaps.isFirewallAllowList(chain)
+            final int ruleToRemoveMatch = BpfNetMapsUtils.isFirewallAllowList(chain)
                     ? FIREWALL_RULE_DENY : FIREWALL_RULE_ALLOW;
             mBpfNetMaps.setUidRule(chain, TEST_UID, ruleToRemoveMatch);
         }
@@ -698,11 +716,11 @@
         for (final int chain: FIREWALL_CHAINS) {
             final String testCase = "EnabledChains: " + enableChains + " CheckedChain: " + chain;
             if (enableChains.contains(chain)) {
-                final int expectedRule = mBpfNetMaps.isFirewallAllowList(chain)
+                final int expectedRule = BpfNetMapsUtils.isFirewallAllowList(chain)
                         ? FIREWALL_RULE_ALLOW : FIREWALL_RULE_DENY;
                 assertEquals(testCase, expectedRule, mBpfNetMaps.getUidRule(chain, TEST_UID));
             } else {
-                final int expectedRule = mBpfNetMaps.isFirewallAllowList(chain)
+                final int expectedRule = BpfNetMapsUtils.isFirewallAllowList(chain)
                         ? FIREWALL_RULE_DENY : FIREWALL_RULE_ALLOW;
                 assertEquals(testCase, expectedRule, mBpfNetMaps.getUidRule(chain, TEST_UID));
             }
@@ -745,7 +763,7 @@
     public void testGetUidRuleNoEntry() throws Exception {
         mUidOwnerMap.clear();
         for (final int chain: FIREWALL_CHAINS) {
-            final int expectedRule = mBpfNetMaps.isFirewallAllowList(chain)
+            final int expectedRule = BpfNetMapsUtils.isFirewallAllowList(chain)
                     ? FIREWALL_RULE_DENY : FIREWALL_RULE_ALLOW;
             assertEquals(expectedRule, mBpfNetMaps.getUidRule(chain, TEST_UID));
         }
@@ -1154,6 +1172,21 @@
         assertDumpContains(getDump(), "cookie=123 tag=0x789 uid=456");
     }
 
+    private void doTestDumpDataSaverConfig(final short value, final boolean expected)
+            throws Exception {
+        mDataSaverEnabledMap.updateEntry(DATA_SAVER_ENABLED_KEY, new U8(value));
+        assertDumpContains(getDump(),
+                "sDataSaverEnabledMap: " + expected);
+    }
+
+    @Test
+    @IgnoreUpTo(Build.VERSION_CODES.S_V2)
+    public void testDumpDataSaverConfig() throws Exception {
+        doTestDumpDataSaverConfig(DATA_SAVER_DISABLED, false);
+        doTestDumpDataSaverConfig(DATA_SAVER_ENABLED, true);
+        doTestDumpDataSaverConfig((short) 2, true);
+    }
+
     @Test
     public void testGetUids() throws ErrnoException {
         final int uid0 = TEST_UIDS[0];
@@ -1182,4 +1215,73 @@
         assertThrows(expected,
                 () -> mBpfNetMaps.getUidsWithAllowRuleOnAllowListChain(FIREWALL_CHAIN_OEM_DENY_1));
     }
+
+    @Test
+    @IgnoreAfter(Build.VERSION_CODES.S_V2)
+    public void testSetDataSaverEnabledBeforeT() {
+        for (boolean enable : new boolean[]{true, false}) {
+            assertThrows(UnsupportedOperationException.class,
+                    () -> mBpfNetMaps.setDataSaverEnabled(enable));
+        }
+    }
+
+    @Test
+    @IgnoreUpTo(Build.VERSION_CODES.S_V2)
+    public void testSetDataSaverEnabled() throws Exception {
+        for (boolean enable : new boolean[]{true, false}) {
+            mBpfNetMaps.setDataSaverEnabled(enable);
+            assertEquals(enable ? DATA_SAVER_ENABLED : DATA_SAVER_DISABLED,
+                    mDataSaverEnabledMap.getValue(DATA_SAVER_ENABLED_KEY).val);
+        }
+    }
+
+    @Test
+    @IgnoreUpTo(Build.VERSION_CODES.S_V2)
+    public void testSetIngressDiscardRule_V4address() throws Exception {
+        mBpfNetMaps.setIngressDiscardRule(TEST_V4_ADDRESS, TEST_IF_NAME);
+        final IngressDiscardValue val = mIngressDiscardMap.getValue(new IngressDiscardKey(
+                TEST_V4_ADDRESS));
+        assertEquals(TEST_IF_INDEX, val.iif1);
+        assertEquals(TEST_IF_INDEX, val.iif2);
+    }
+
+    @Test
+    @IgnoreUpTo(Build.VERSION_CODES.S_V2)
+    public void testSetIngressDiscardRule_V6address() throws Exception {
+        mBpfNetMaps.setIngressDiscardRule(TEST_V6_ADDRESS, TEST_IF_NAME);
+        final IngressDiscardValue val =
+                mIngressDiscardMap.getValue(new IngressDiscardKey(TEST_V6_ADDRESS));
+        assertEquals(TEST_IF_INDEX, val.iif1);
+        assertEquals(TEST_IF_INDEX, val.iif2);
+    }
+
+    @Test
+    @IgnoreUpTo(Build.VERSION_CODES.S_V2)
+    public void testRemoveIngressDiscardRule() throws Exception {
+        mBpfNetMaps.setIngressDiscardRule(TEST_V4_ADDRESS, TEST_IF_NAME);
+        mBpfNetMaps.setIngressDiscardRule(TEST_V6_ADDRESS, TEST_IF_NAME);
+        final IngressDiscardKey v4Key = new IngressDiscardKey(TEST_V4_ADDRESS);
+        final IngressDiscardKey v6Key = new IngressDiscardKey(TEST_V6_ADDRESS);
+        assertTrue(mIngressDiscardMap.containsKey(v4Key));
+        assertTrue(mIngressDiscardMap.containsKey(v6Key));
+
+        mBpfNetMaps.removeIngressDiscardRule(TEST_V4_ADDRESS);
+        assertFalse(mIngressDiscardMap.containsKey(v4Key));
+        assertTrue(mIngressDiscardMap.containsKey(v6Key));
+
+        mBpfNetMaps.removeIngressDiscardRule(TEST_V6_ADDRESS);
+        assertFalse(mIngressDiscardMap.containsKey(v4Key));
+        assertFalse(mIngressDiscardMap.containsKey(v6Key));
+    }
+
+    @Test
+    @IgnoreUpTo(Build.VERSION_CODES.S_V2)
+    public void testDumpIngressDiscardRule() throws Exception {
+        mBpfNetMaps.setIngressDiscardRule(TEST_V4_ADDRESS, TEST_IF_NAME);
+        mBpfNetMaps.setIngressDiscardRule(TEST_V6_ADDRESS, TEST_IF_NAME);
+        final String dump = getDump();
+        assertDumpContains(dump, TEST_V4_ADDRESS.getHostAddress());
+        assertDumpContains(dump, TEST_V6_ADDRESS.getHostAddress());
+        assertDumpContains(dump, TEST_IF_INDEX + "(" + TEST_IF_NAME + ")");
+    }
 }
diff --git a/tests/unit/java/com/android/server/ConnectivityServiceTest.java b/tests/unit/java/com/android/server/ConnectivityServiceTest.java
index 2fccdcb..aae37e5 100755
--- a/tests/unit/java/com/android/server/ConnectivityServiceTest.java
+++ b/tests/unit/java/com/android/server/ConnectivityServiceTest.java
@@ -30,6 +30,7 @@
 import static android.Manifest.permission.NETWORK_SETUP_WIZARD;
 import static android.Manifest.permission.NETWORK_STACK;
 import static android.Manifest.permission.PACKET_KEEPALIVE_OFFLOAD;
+import static android.Manifest.permission.READ_DEVICE_CONFIG;
 import static android.app.ActivityManager.UidFrozenStateChangedCallback.UID_FROZEN_STATE_FROZEN;
 import static android.app.ActivityManager.UidFrozenStateChangedCallback.UID_FROZEN_STATE_UNFROZEN;
 import static android.app.PendingIntent.FLAG_IMMUTABLE;
@@ -76,6 +77,7 @@
 import static android.net.ConnectivityManager.TYPE_ETHERNET;
 import static android.net.ConnectivityManager.TYPE_MOBILE;
 import static android.net.ConnectivityManager.TYPE_MOBILE_SUPL;
+import static android.net.ConnectivityManager.TYPE_NONE;
 import static android.net.ConnectivityManager.TYPE_VPN;
 import static android.net.ConnectivityManager.TYPE_WIFI;
 import static android.net.ConnectivitySettingsManager.PRIVATE_DNS_MODE_OFF;
@@ -128,6 +130,7 @@
 import static android.net.NetworkCapabilities.REDACT_FOR_LOCAL_MAC_ADDRESS;
 import static android.net.NetworkCapabilities.REDACT_FOR_NETWORK_SETTINGS;
 import static android.net.NetworkCapabilities.REDACT_NONE;
+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_TEST;
@@ -152,6 +155,7 @@
 import static android.system.OsConstants.IPPROTO_TCP;
 
 import static com.android.server.ConnectivityService.DELAY_DESTROY_FROZEN_SOCKETS_VERSION;
+import static com.android.net.module.util.DeviceConfigUtils.TETHERING_MODULE_NAME;
 import static com.android.server.ConnectivityService.KEY_DESTROY_FROZEN_SOCKETS_VERSION;
 import static com.android.server.ConnectivityService.MAX_NETWORK_REQUESTS_PER_SYSTEM_UID;
 import static com.android.server.ConnectivityService.PREFERENCE_ORDER_MOBILE_DATA_PREFERERRED;
@@ -250,6 +254,7 @@
 import android.content.Intent;
 import android.content.IntentFilter;
 import android.content.pm.ApplicationInfo;
+import android.content.pm.ModuleInfo;
 import android.content.pm.PackageInfo;
 import android.content.pm.PackageManager;
 import android.content.pm.ResolveInfo;
@@ -412,9 +417,7 @@
 import com.android.server.connectivity.QosCallbackTracker;
 import com.android.server.connectivity.TcpKeepaliveController;
 import com.android.server.connectivity.UidRangeUtils;
-import com.android.server.connectivity.Vpn;
 import com.android.server.connectivity.VpnProfileStore;
-import com.android.server.net.LockdownVpnTracker;
 import com.android.server.net.NetworkPinner;
 import com.android.testutils.DevSdkIgnoreRule;
 import com.android.testutils.DevSdkIgnoreRunner;
@@ -466,6 +469,7 @@
 import java.util.Map;
 import java.util.Objects;
 import java.util.Set;
+import java.util.UUID;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.Executor;
@@ -930,24 +934,39 @@
         return appUid + (firstSdkSandboxUid - Process.FIRST_APPLICATION_UID);
     }
 
-    // This function assumes the UID range for user 0 ([1, 99999])
-    private static UidRangeParcel[] uidRangeParcelsExcludingUids(Integer... excludedUids) {
-        int start = 1;
-        Arrays.sort(excludedUids);
-        List<UidRangeParcel> parcels = new ArrayList<UidRangeParcel>();
+    // Create the list of ranges for the primary user (User 0), excluding excludedUids.
+    private static List<Range<Integer>> intRangesPrimaryExcludingUids(List<Integer> excludedUids) {
+        final List<Integer> excludedUidsList = new ArrayList<>(excludedUids);
+        // Uid 0 is always excluded
+        if (!excludedUidsList.contains(0)) {
+            excludedUidsList.add(0);
+        }
+        return intRangesExcludingUids(PRIMARY_USER, excludedUidsList);
+    }
+
+    private static List<Range<Integer>> intRangesExcludingUids(int userId,
+            List<Integer> excludedAppIds) {
+        final List<Integer> excludedUids = CollectionUtils.map(excludedAppIds,
+                appId -> UserHandle.getUid(userId, appId));
+        final int userBase = userId * UserHandle.PER_USER_RANGE;
+        final int maxUid = userBase + UserHandle.PER_USER_RANGE - 1;
+
+        int start = userBase;
+        Collections.sort(excludedUids);
+        final List<Range<Integer>> ranges = new ArrayList<>();
         for (int excludedUid : excludedUids) {
             if (excludedUid == start) {
                 start++;
             } else {
-                parcels.add(new UidRangeParcel(start, excludedUid - 1));
+                ranges.add(new Range<>(start, excludedUid - 1));
                 start = excludedUid + 1;
             }
         }
-        if (start <= 99999) {
-            parcels.add(new UidRangeParcel(start, 99999));
+        if (start <= maxUid) {
+            ranges.add(new Range<>(start, maxUid));
         }
 
-        return parcels.toArray(new UidRangeParcel[0]);
+        return ranges;
     }
 
     private void waitForIdle() {
@@ -1477,13 +1496,8 @@
         return uidRangesForUids(CollectionUtils.toIntArray(uids));
     }
 
-    private static Looper startHandlerThreadAndReturnLooper() {
-        final HandlerThread handlerThread = new HandlerThread("MockVpnThread");
-        handlerThread.start();
-        return handlerThread.getLooper();
-    }
-
-    private class MockVpn extends Vpn implements TestableNetworkCallback.HasNetwork {
+    // Helper class to mock vpn interaction.
+    private class MockVpn implements TestableNetworkCallback.HasNetwork {
         // Note : Please do not add any new instrumentation here. If you need new instrumentation,
         // please add it in CSTest and use subclasses of CSTest instead of adding more
         // tools in ConnectivityServiceTest.
@@ -1491,52 +1505,22 @@
         // Careful ! This is different from mNetworkAgent, because MockNetworkAgent does
         // not inherit from NetworkAgent.
         private TestNetworkAgentWrapper mMockNetworkAgent;
+        // Initialize a stored NetworkCapabilities following the defaults of VPN. The TransportInfo
+        // should at least be updated to a valid VPN type before usage, see registerAgent(...).
+        private NetworkCapabilities mNetworkCapabilities = new NetworkCapabilities.Builder()
+                .addTransportType(NetworkCapabilities.TRANSPORT_VPN)
+                .removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
+                .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VCN_MANAGED)
+                .setTransportInfo(new VpnTransportInfo(
+                        VpnManager.TYPE_VPN_NONE,
+                        null /* sessionId */,
+                        false /* bypassable */,
+                        false /* longLivedTcpConnectionsExpensive */))
+                .build();
         private boolean mAgentRegistered = false;
 
         private int mVpnType = VpnManager.TYPE_VPN_SERVICE;
-        private UnderlyingNetworkInfo mUnderlyingNetworkInfo;
-
-        // These ConditionVariables allow tests to wait for LegacyVpnRunner to be stopped/started.
-        // TODO: this scheme is ad-hoc and error-prone because it does not fail if, for example, the
-        // test expects two starts in a row, or even if the production code calls start twice in a
-        // row. find a better solution. Simply putting a method to create a LegacyVpnRunner into
-        // Vpn.Dependencies doesn't work because LegacyVpnRunner is not a static class and has
-        // extensive access into the internals of Vpn.
-        private ConditionVariable mStartLegacyVpnCv = new ConditionVariable();
-        private ConditionVariable mStopVpnRunnerCv = new ConditionVariable();
-
-        public MockVpn(int userId) {
-            super(startHandlerThreadAndReturnLooper(), mServiceContext,
-                    new Dependencies() {
-                        @Override
-                        public boolean isCallerSystem() {
-                            return true;
-                        }
-
-                        @Override
-                        public DeviceIdleInternal getDeviceIdleInternal() {
-                            return mDeviceIdleInternal;
-                        }
-                    },
-                    mNetworkManagementService, mMockNetd, userId, mVpnProfileStore,
-                    new SystemServices(mServiceContext) {
-                        @Override
-                        public String settingsSecureGetStringForUser(String key, int userId) {
-                            switch (key) {
-                                // Settings keys not marked as @Readable are not readable from
-                                // non-privileged apps, unless marked as testOnly=true
-                                // (atest refuses to install testOnly=true apps), even if mocked
-                                // in the content provider, because
-                                // Settings.Secure.NameValueCache#getStringForUser checks the key
-                                // before querying the mock settings provider.
-                                case Settings.Secure.ALWAYS_ON_VPN_APP:
-                                    return null;
-                                default:
-                                    return super.settingsSecureGetStringForUser(key, userId);
-                            }
-                        }
-                    }, new Ikev2SessionCreator());
-        }
+        private String mSessionKey;
 
         public void setUids(Set<UidRange> uids) {
             mNetworkCapabilities.setUids(UidRange.toIntRanges(uids));
@@ -1549,7 +1533,6 @@
             mVpnType = vpnType;
         }
 
-        @Override
         public Network getNetwork() {
             return (mMockNetworkAgent == null) ? null : mMockNetworkAgent.getNetwork();
         }
@@ -1558,7 +1541,6 @@
             return null == mMockNetworkAgent ? null : mMockNetworkAgent.getNetworkAgentConfig();
         }
 
-        @Override
         public int getActiveVpnType() {
             return mVpnType;
         }
@@ -1572,14 +1554,11 @@
         private void registerAgent(boolean isAlwaysMetered, Set<UidRange> uids, LinkProperties lp)
                 throws Exception {
             if (mAgentRegistered) throw new IllegalStateException("already registered");
-            updateState(NetworkInfo.DetailedState.CONNECTING, "registerAgent");
-            mConfig = new VpnConfig();
-            mConfig.session = "MySession12345";
+            final String session = "MySession12345";
             setUids(uids);
             if (!isAlwaysMetered) mNetworkCapabilities.addCapability(NET_CAPABILITY_NOT_METERED);
-            mInterface = VPN_IFNAME;
             mNetworkCapabilities.setTransportInfo(new VpnTransportInfo(getActiveVpnType(),
-                    mConfig.session));
+                    session));
             mMockNetworkAgent = new TestNetworkAgentWrapper(TRANSPORT_VPN, lp,
                     mNetworkCapabilities);
             mMockNetworkAgent.waitForIdle(TIMEOUT_MS);
@@ -1593,9 +1572,7 @@
             mAgentRegistered = true;
             verify(mMockNetd).networkCreate(nativeNetworkConfigVpn(getNetwork().netId,
                     !mMockNetworkAgent.isBypassableVpn(), mVpnType));
-            updateState(NetworkInfo.DetailedState.CONNECTED, "registerAgent");
             mNetworkCapabilities.set(mMockNetworkAgent.getNetworkCapabilities());
-            mNetworkAgent = mMockNetworkAgent.getNetworkAgent();
         }
 
         private void registerAgent(Set<UidRange> uids) throws Exception {
@@ -1655,57 +1632,63 @@
         public void disconnect() {
             if (mMockNetworkAgent != null) {
                 mMockNetworkAgent.disconnect();
-                updateState(NetworkInfo.DetailedState.DISCONNECTED, "disconnect");
             }
             mAgentRegistered = false;
             setUids(null);
             // Remove NET_CAPABILITY_INTERNET or MockNetworkAgent will refuse to connect later on.
             mNetworkCapabilities.removeCapability(NET_CAPABILITY_INTERNET);
-            mInterface = null;
         }
 
-        @Override
-        public void startLegacyVpnRunner() {
-            mStartLegacyVpnCv.open();
+        private void startLegacyVpn() {
+            // Do nothing.
         }
 
-        public void expectStartLegacyVpnRunner() {
-            assertTrue("startLegacyVpnRunner not called after " + TIMEOUT_MS + " ms",
-                    mStartLegacyVpnCv.block(TIMEOUT_MS));
-
-            // startLegacyVpn calls stopVpnRunnerPrivileged, which will open mStopVpnRunnerCv, just
-            // before calling startLegacyVpnRunner. Restore mStopVpnRunnerCv, so the test can expect
-            // that the VpnRunner is stopped and immediately restarted by calling
-            // expectStartLegacyVpnRunner() and expectStopVpnRunnerPrivileged() back-to-back.
-            mStopVpnRunnerCv = new ConditionVariable();
+        // Mock the interaction of IkeV2VpnRunner start. In the context of ConnectivityService,
+        // setVpnDefaultForUids() is the main interaction and a sessionKey is stored.
+        private void startPlatformVpn() {
+            mSessionKey = UUID.randomUUID().toString();
+            // Assuming no disallowed applications
+            final Set<Range<Integer>> ranges = UidRange.toIntRanges(Set.of(PRIMARY_UIDRANGE));
+            mCm.setVpnDefaultForUids(mSessionKey, ranges);
+            // Wait for vpn network preference updates.
+            waitForIdle();
         }
 
-        @Override
-        public void stopVpnRunnerPrivileged() {
-            if (mVpnRunner != null) {
-                super.stopVpnRunnerPrivileged();
-                disconnect();
-                mStartLegacyVpnCv = new ConditionVariable();
+        public void startLegacyVpnPrivileged(VpnProfile profile,
+                @Nullable Network underlying, @NonNull LinkProperties egress) {
+            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");
             }
-            mVpnRunner = null;
-            mStopVpnRunnerCv.open();
         }
 
-        public void expectStopVpnRunnerPrivileged() {
-            assertTrue("stopVpnRunnerPrivileged not called after " + TIMEOUT_MS + " ms",
-                    mStopVpnRunnerCv.block(TIMEOUT_MS));
+        public void stopVpnRunnerPrivileged() {
+            if (mSessionKey != null) {
+                // Clear vpn network preference.
+                mCm.setVpnDefaultForUids(mSessionKey, Collections.EMPTY_LIST);
+                mSessionKey = null;
+            }
+            disconnect();
         }
 
-        @Override
-        public synchronized UnderlyingNetworkInfo getUnderlyingNetworkInfo() {
-            if (mUnderlyingNetworkInfo != null) return mUnderlyingNetworkInfo;
-
-            return super.getUnderlyingNetworkInfo();
-        }
-
-        private synchronized void setUnderlyingNetworkInfo(
-                UnderlyingNetworkInfo underlyingNetworkInfo) {
-            mUnderlyingNetworkInfo = underlyingNetworkInfo;
+        public boolean setUnderlyingNetworks(@Nullable Network[] networks) {
+            if (!mAgentRegistered) return false;
+            mMockNetworkAgent.setUnderlyingNetworks(
+                    (networks == null) ? null : Arrays.asList(networks));
+            return true;
         }
     }
 
@@ -1718,6 +1701,12 @@
         return ranges.stream().map(r -> new UidRangeParcel(r, r)).toArray(UidRangeParcel[]::new);
     }
 
+    private static UidRangeParcel[] intToUidRangeStableParcels(
+            final @NonNull List<Range<Integer>> ranges) {
+        return ranges.stream().map(
+                r -> new UidRangeParcel(r.getLower(), r.getUpper())).toArray(UidRangeParcel[]::new);
+    }
+
     private void assertVpnTransportInfo(NetworkCapabilities nc, int type) {
         assertNotNull(nc);
         final TransportInfo ti = nc.getTransportInfo();
@@ -1732,11 +1721,6 @@
         waitForIdle();
     }
 
-    private void mockVpn(int uid) {
-        int userId = UserHandle.getUserId(uid);
-        mMockVpn = new MockVpn(userId);
-    }
-
     private void mockUidNetworkingBlocked() {
         doAnswer(i -> isUidBlocked(mBlockedReasons, i.getArgument(1))
         ).when(mNetworkPolicyManager).isUidNetworkingBlocked(anyInt(), anyBoolean());
@@ -1850,6 +1834,8 @@
     private static final UserHandle TERTIARY_USER_HANDLE = new UserHandle(TERTIARY_USER);
 
     private static final int RESTRICTED_USER = 1;
+    private static final UidRange RESTRICTED_USER_UIDRANGE =
+            UidRange.createForUser(UserHandle.of(RESTRICTED_USER));
     private static final UserInfo RESTRICTED_USER_INFO = new UserInfo(RESTRICTED_USER, "",
             UserInfo.FLAG_RESTRICTED);
     static {
@@ -1903,6 +1889,7 @@
         mServiceContext.setPermission(CONTROL_OEM_PAID_NETWORK_PREFERENCE, PERMISSION_GRANTED);
         mServiceContext.setPermission(PACKET_KEEPALIVE_OFFLOAD, PERMISSION_GRANTED);
         mServiceContext.setPermission(CONNECTIVITY_USE_RESTRICTED_NETWORKS, PERMISSION_GRANTED);
+        mServiceContext.setPermission(READ_DEVICE_CONFIG, PERMISSION_GRANTED);
 
         mAlarmManagerThread = new HandlerThread("TestAlarmManager");
         mAlarmManagerThread.start();
@@ -1945,7 +1932,7 @@
         mService.systemReadyInternal();
         verify(mMockDnsResolver).registerUnsolicitedEventListener(any());
 
-        mockVpn(Process.myUid());
+        mMockVpn = new MockVpn();
         mCm.bindProcessToNetwork(null);
         mQosCallbackTracker = mock(QosCallbackTracker.class);
 
@@ -2058,7 +2045,8 @@
 
         @Override
         public CarrierPrivilegeAuthenticator makeCarrierPrivilegeAuthenticator(
-                @NonNull final Context context, @NonNull final TelephonyManager tm) {
+                @NonNull final Context context,
+                @NonNull final TelephonyManager tm) {
             return mDeps.isAtLeastT() ? mCarrierPrivilegeAuthenticator : null;
         }
 
@@ -2150,6 +2138,7 @@
         public boolean isFeatureEnabled(Context context, String name) {
             switch (name) {
                 case ConnectivityFlags.NO_REMATCH_ALL_REQUESTS_ON_REGISTER:
+                case ConnectivityFlags.CARRIER_SERVICE_CHANGED_USE_CALLBACK:
                     return true;
                 case KEY_DESTROY_FROZEN_SOCKETS_VERSION:
                     return true;
@@ -2252,6 +2241,11 @@
         }
 
         @Override
+        public int getBpfProgramId(final int attachType) {
+            return 0;
+        }
+
+        @Override
         public BroadcastOptionsShim makeBroadcastOptionsShim(BroadcastOptions options) {
             reset(mBroadcastOptionsShim);
             return mBroadcastOptionsShim;
@@ -2404,6 +2398,7 @@
         final String myPackageName = mContext.getPackageName();
         final PackageInfo myPackageInfo = mContext.getPackageManager().getPackageInfo(
                 myPackageName, PackageManager.GET_PERMISSIONS);
+        myPackageInfo.setLongVersionCode(9_999_999L);
         doReturn(new String[] {myPackageName}).when(mPackageManager)
                 .getPackagesForUid(Binder.getCallingUid());
         doReturn(myPackageInfo).when(mPackageManager).getPackageInfoAsUser(
@@ -2415,6 +2410,13 @@
                 buildPackageInfo(/* SYSTEM */ false, VPN_UID)
         })).when(mPackageManager).getInstalledPackagesAsUser(eq(GET_PERMISSIONS), anyInt());
 
+        final ModuleInfo moduleInfo = new ModuleInfo();
+        moduleInfo.setPackageName(TETHERING_MODULE_NAME);
+        doReturn(moduleInfo).when(mPackageManager)
+                .getModuleInfo(TETHERING_MODULE_NAME, PackageManager.MODULE_APEX_NAME);
+        doReturn(myPackageInfo).when(mPackageManager)
+                .getPackageInfo(TETHERING_MODULE_NAME, PackageManager.MATCH_APEX);
+
         // Create a fake always-on VPN package.
         final int userId = UserHandle.getCallingUserId();
         final ApplicationInfo applicationInfo = new ApplicationInfo();
@@ -4286,7 +4288,9 @@
         testFactory.terminate();
         testFactory.assertNoRequestChanged();
         if (networkCallback != null) mCm.unregisterNetworkCallback(networkCallback);
-        handlerThread.quit();
+
+        handlerThread.quitSafely();
+        handlerThread.join();
     }
 
     @Test
@@ -4347,6 +4351,8 @@
         expectNoRequestChanged(testFactoryAll); // still seeing the request
 
         mWiFiAgent.disconnect();
+        handlerThread.quitSafely();
+        handlerThread.join();
     }
 
     @Test
@@ -4380,7 +4386,8 @@
                 }
             }
         }
-        handlerThread.quit();
+        handlerThread.quitSafely();
+        handlerThread.join();
     }
 
     @Test
@@ -4871,6 +4878,34 @@
     }
 
     @Test
+    public void testNoAvoidCaptivePortalOnWearProxy() throws Exception {
+        // Bring up a BLUETOOTH network which is companion proxy on wear
+        // then set captive portal.
+        mockHasSystemFeature(PackageManager.FEATURE_WATCH, true);
+        setCaptivePortalMode(ConnectivitySettingsManager.CAPTIVE_PORTAL_MODE_AVOID);
+        TestNetworkAgentWrapper btAgent = new TestNetworkAgentWrapper(TRANSPORT_BLUETOOTH);
+        final String firstRedirectUrl = "http://example.com/firstPath";
+
+        btAgent.connectWithCaptivePortal(firstRedirectUrl, false /* privateDnsProbeSent */);
+        btAgent.assertNotDisconnected(TIMEOUT_MS);
+    }
+
+    @Test
+    public void testAvoidCaptivePortalOnBluetooth() throws Exception {
+        // When not on Wear, BLUETOOTH is just regular network,
+        // then set captive portal.
+        mockHasSystemFeature(PackageManager.FEATURE_WATCH, false);
+        setCaptivePortalMode(ConnectivitySettingsManager.CAPTIVE_PORTAL_MODE_AVOID);
+        TestNetworkAgentWrapper btAgent = new TestNetworkAgentWrapper(TRANSPORT_BLUETOOTH);
+        final String firstRedirectUrl = "http://example.com/firstPath";
+
+        btAgent.connectWithCaptivePortal(firstRedirectUrl, false /* privateDnsProbeSent */);
+
+        btAgent.expectDisconnected();
+        btAgent.expectPreventReconnectReceived();
+    }
+
+    @Test
     public void testCaptivePortalApi() throws Exception {
         mServiceContext.setPermission(NETWORK_SETTINGS, PERMISSION_GRANTED);
 
@@ -5973,7 +6008,8 @@
             testFactory.assertNoRequestChanged();
         } finally {
             mCm.unregisterNetworkCallback(cellNetworkCallback);
-            handlerThread.quit();
+            handlerThread.quitSafely();
+            handlerThread.join();
         }
     }
 
@@ -6563,7 +6599,8 @@
             }
         } finally {
             testFactory.terminate();
-            handlerThread.quit();
+            handlerThread.quitSafely();
+            handlerThread.join();
         }
     }
 
@@ -9361,11 +9398,11 @@
                         && c.hasTransport(TRANSPORT_WIFI));
         callback.expectCaps(mWiFiAgent, c -> c.hasCapability(NET_CAPABILITY_VALIDATED));
 
-        doReturn(UserHandle.getUid(RESTRICTED_USER, VPN_UID)).when(mPackageManager)
-                .getPackageUidAsUser(ALWAYS_ON_PACKAGE, RESTRICTED_USER);
-
-        // New user added
-        mMockVpn.onUserAdded(RESTRICTED_USER);
+        // New user added, this updates the Vpn uids, coverage in VpnTest.
+        // This is equivalent to `mMockVpn.onUserAdded(RESTRICTED_USER);`
+        final Set<UidRange> ranges = uidRangesForUids(uid);
+        ranges.add(RESTRICTED_USER_UIDRANGE);
+        mMockVpn.setUids(ranges);
 
         // Expect that the VPN UID ranges contain both |uid| and the UID range for the newly-added
         // restricted user.
@@ -9390,7 +9427,9 @@
                 && !c.hasTransport(TRANSPORT_WIFI));
 
         // User removed and expect to lose the UID range for the restricted user.
-        mMockVpn.onUserRemoved(RESTRICTED_USER);
+        // This updates the Vpn uids, coverage in VpnTest.
+        // This is equivalent to `mMockVpn.onUserRemoved(RESTRICTED_USER);`
+        mMockVpn.setUids(uidRangesForUids(uid));
 
         // Expect that the VPN gains the UID range for the restricted user, and that the capability
         // change made just before that (i.e., loss of TRANSPORT_WIFI) is preserved.
@@ -9431,8 +9470,16 @@
         assertNotNull(mCm.getActiveNetworkForUid(restrictedUid));
 
         // Enable always-on VPN lockdown. The main user loses network access because no VPN is up.
-        final ArrayList<String> allowList = new ArrayList<>();
-        mMockVpn.setAlwaysOnPackage(ALWAYS_ON_PACKAGE, true /* lockdown */, allowList);
+        // Coverage in VpnTest.
+        final List<Integer> excludedUids = new ArrayList<>();
+        excludedUids.add(VPN_UID);
+        if (mDeps.isAtLeastT()) {
+            // On T onwards, the corresponding SDK sandbox UID should also be excluded
+            excludedUids.add(toSdkSandboxUid(VPN_UID));
+        }
+        final List<Range<Integer>> primaryRanges = intRangesPrimaryExcludingUids(excludedUids);
+        mCm.setRequireVpnForUids(true, primaryRanges);
+
         waitForIdle();
         assertNull(mCm.getActiveNetworkForUid(uid));
         // This is arguably overspecified: a UID that is not running doesn't have an active network.
@@ -9441,32 +9488,28 @@
         assertNotNull(mCm.getActiveNetworkForUid(restrictedUid));
 
         // Start the restricted profile, and check that the UID within it loses network access.
-        doReturn(UserHandle.getUid(RESTRICTED_USER, VPN_UID)).when(mPackageManager)
-                .getPackageUidAsUser(ALWAYS_ON_PACKAGE, RESTRICTED_USER);
-        doReturn(asList(PRIMARY_USER_INFO, RESTRICTED_USER_INFO)).when(mUserManager)
-                .getAliveUsers();
         // TODO: check that VPN app within restricted profile still has access, etc.
-        mMockVpn.onUserAdded(RESTRICTED_USER);
-        final Intent addedIntent = new Intent(ACTION_USER_ADDED);
-        addedIntent.putExtra(Intent.EXTRA_USER, UserHandle.of(RESTRICTED_USER));
-        addedIntent.putExtra(Intent.EXTRA_USER_HANDLE, RESTRICTED_USER);
-        processBroadcast(addedIntent);
+        // Add a restricted user.
+        // This is equivalent to `mMockVpn.onUserAdded(RESTRICTED_USER);`, coverage in VpnTest.
+        final List<Range<Integer>> restrictedRanges =
+                intRangesExcludingUids(RESTRICTED_USER, excludedUids);
+        mCm.setRequireVpnForUids(true, restrictedRanges);
+        waitForIdle();
+
         assertNull(mCm.getActiveNetworkForUid(uid));
         assertNull(mCm.getActiveNetworkForUid(restrictedUid));
 
         // Stop the restricted profile, and check that the UID within it has network access again.
-        doReturn(asList(PRIMARY_USER_INFO)).when(mUserManager).getAliveUsers();
+        // Remove the restricted user.
+        // This is equivalent to `mMockVpn.onUserRemoved(RESTRICTED_USER);`, coverage in VpnTest.
+        mCm.setRequireVpnForUids(false, restrictedRanges);
+        waitForIdle();
 
-        // Send a USER_REMOVED broadcast and expect to lose the UID range for the restricted user.
-        mMockVpn.onUserRemoved(RESTRICTED_USER);
-        final Intent removedIntent = new Intent(ACTION_USER_REMOVED);
-        removedIntent.putExtra(Intent.EXTRA_USER, UserHandle.of(RESTRICTED_USER));
-        removedIntent.putExtra(Intent.EXTRA_USER_HANDLE, RESTRICTED_USER);
-        processBroadcast(removedIntent);
         assertNull(mCm.getActiveNetworkForUid(uid));
         assertNotNull(mCm.getActiveNetworkForUid(restrictedUid));
 
-        mMockVpn.setAlwaysOnPackage(null, false /* lockdown */, allowList);
+        mCm.setRequireVpnForUids(false, primaryRanges);
+
         waitForIdle();
     }
 
@@ -9919,18 +9962,20 @@
                 new Handler(ConnectivityThread.getInstanceLooper()));
 
         final int uid = Process.myUid();
-        final ArrayList<String> allowList = new ArrayList<>();
-        mMockVpn.setAlwaysOnPackage(ALWAYS_ON_PACKAGE, true /* lockdown */, allowList);
-        waitForIdle();
 
-        final Set<Integer> excludedUids = new ArraySet<Integer>();
+        // Enable always-on VPN lockdown, coverage in VpnTest.
+        final List<Integer> excludedUids = new ArrayList<Integer>();
         excludedUids.add(VPN_UID);
         if (mDeps.isAtLeastT()) {
             // On T onwards, the corresponding SDK sandbox UID should also be excluded
             excludedUids.add(toSdkSandboxUid(VPN_UID));
         }
-        final UidRangeParcel[] uidRangeParcels = uidRangeParcelsExcludingUids(
-                excludedUids.toArray(new Integer[0]));
+
+        final List<Range<Integer>> primaryRanges = intRangesPrimaryExcludingUids(excludedUids);
+        mCm.setRequireVpnForUids(true, primaryRanges);
+        waitForIdle();
+
+        final UidRangeParcel[] uidRangeParcels = intToUidRangeStableParcels(primaryRanges);
         InOrder inOrder = inOrder(mMockNetd);
         expectNetworkRejectNonSecureVpn(inOrder, true, uidRangeParcels);
 
@@ -9950,7 +9995,8 @@
         assertNetworkInfo(TYPE_WIFI, DetailedState.BLOCKED);
 
         // Disable lockdown, expect to see the network unblocked.
-        mMockVpn.setAlwaysOnPackage(null, false /* lockdown */, allowList);
+        mCm.setRequireVpnForUids(false, primaryRanges);
+        waitForIdle();
         callback.expect(BLOCKED_STATUS, mWiFiAgent, cb -> !cb.getBlocked());
         defaultCallback.expect(BLOCKED_STATUS, mWiFiAgent, cb -> !cb.getBlocked());
         vpnUidCallback.assertNoCallback();
@@ -9963,22 +10009,25 @@
         assertNetworkInfo(TYPE_MOBILE, DetailedState.DISCONNECTED);
         assertNetworkInfo(TYPE_WIFI, DetailedState.CONNECTED);
 
-        // Add our UID to the allowlist and re-enable lockdown, expect network is not blocked.
-        allowList.add(TEST_PACKAGE_NAME);
-        mMockVpn.setAlwaysOnPackage(ALWAYS_ON_PACKAGE, true /* lockdown */, allowList);
+        // Add our UID to the allowlist, expect network is not blocked. Coverage in VpnTest.
+        excludedUids.add(uid);
+        if (mDeps.isAtLeastT()) {
+            // On T onwards, the corresponding SDK sandbox UID should also be excluded
+            excludedUids.add(toSdkSandboxUid(uid));
+        }
+        final List<Range<Integer>> primaryRangesExcludingUid =
+                intRangesPrimaryExcludingUids(excludedUids);
+        mCm.setRequireVpnForUids(true, primaryRangesExcludingUid);
+        waitForIdle();
+
         callback.assertNoCallback();
         defaultCallback.assertNoCallback();
         vpnUidCallback.assertNoCallback();
         vpnUidDefaultCallback.assertNoCallback();
         vpnDefaultCallbackAsUid.assertNoCallback();
 
-        excludedUids.add(uid);
-        if (mDeps.isAtLeastT()) {
-            // On T onwards, the corresponding SDK sandbox UID should also be excluded
-            excludedUids.add(toSdkSandboxUid(uid));
-        }
-        final UidRangeParcel[] uidRangeParcelsAlsoExcludingUs = uidRangeParcelsExcludingUids(
-                excludedUids.toArray(new Integer[0]));
+        final UidRangeParcel[] uidRangeParcelsAlsoExcludingUs =
+                intToUidRangeStableParcels(primaryRangesExcludingUid);
         expectNetworkRejectNonSecureVpn(inOrder, true, uidRangeParcelsAlsoExcludingUs);
         assertEquals(mWiFiAgent.getNetwork(), mCm.getActiveNetworkForUid(VPN_UID));
         assertEquals(mWiFiAgent.getNetwork(), mCm.getActiveNetwork());
@@ -10001,15 +10050,15 @@
         assertNetworkInfo(TYPE_MOBILE, DetailedState.DISCONNECTED);
         assertNetworkInfo(TYPE_WIFI, DetailedState.CONNECTED);
 
-        // Disable lockdown, remove our UID from the allowlist, and re-enable lockdown.
-        // Everything should now be blocked.
-        mMockVpn.setAlwaysOnPackage(null, false /* lockdown */, allowList);
+        // Disable lockdown
+        mCm.setRequireVpnForUids(false, primaryRangesExcludingUid);
         waitForIdle();
         expectNetworkRejectNonSecureVpn(inOrder, false, uidRangeParcelsAlsoExcludingUs);
-        allowList.clear();
-        mMockVpn.setAlwaysOnPackage(ALWAYS_ON_PACKAGE, true /* lockdown */, allowList);
+        // Remove our UID from the allowlist, and re-enable lockdown.
+        mCm.setRequireVpnForUids(true, primaryRanges);
         waitForIdle();
         expectNetworkRejectNonSecureVpn(inOrder, true, uidRangeParcels);
+        // Everything should now be blocked.
         defaultCallback.expect(BLOCKED_STATUS, mWiFiAgent, cb -> cb.getBlocked());
         assertBlockedCallbackInAnyOrder(callback, true, mWiFiAgent, mCellAgent);
         vpnUidCallback.assertNoCallback();
@@ -10022,7 +10071,7 @@
         assertNetworkInfo(TYPE_WIFI, DetailedState.BLOCKED);
 
         // Disable lockdown. Everything is unblocked.
-        mMockVpn.setAlwaysOnPackage(null, false /* lockdown */, allowList);
+        mCm.setRequireVpnForUids(false, primaryRanges);
         defaultCallback.expect(BLOCKED_STATUS, mWiFiAgent, cb -> !cb.getBlocked());
         assertBlockedCallbackInAnyOrder(callback, false, mWiFiAgent, mCellAgent);
         vpnUidCallback.assertNoCallback();
@@ -10034,36 +10083,8 @@
         assertNetworkInfo(TYPE_MOBILE, DetailedState.DISCONNECTED);
         assertNetworkInfo(TYPE_WIFI, DetailedState.CONNECTED);
 
-        // Enable and disable an always-on VPN package without lockdown. Expect no changes.
-        reset(mMockNetd);
-        mMockVpn.setAlwaysOnPackage(ALWAYS_ON_PACKAGE, false /* lockdown */, allowList);
-        inOrder.verify(mMockNetd, never()).networkRejectNonSecureVpn(anyBoolean(), any());
-        callback.assertNoCallback();
-        defaultCallback.assertNoCallback();
-        vpnUidCallback.assertNoCallback();
-        vpnUidDefaultCallback.assertNoCallback();
-        vpnDefaultCallbackAsUid.assertNoCallback();
-        assertEquals(mWiFiAgent.getNetwork(), mCm.getActiveNetworkForUid(VPN_UID));
-        assertEquals(mWiFiAgent.getNetwork(), mCm.getActiveNetwork());
-        assertActiveNetworkInfo(TYPE_WIFI, DetailedState.CONNECTED);
-        assertNetworkInfo(TYPE_MOBILE, DetailedState.DISCONNECTED);
-        assertNetworkInfo(TYPE_WIFI, DetailedState.CONNECTED);
-
-        mMockVpn.setAlwaysOnPackage(null, false /* lockdown */, allowList);
-        inOrder.verify(mMockNetd, never()).networkRejectNonSecureVpn(anyBoolean(), any());
-        callback.assertNoCallback();
-        defaultCallback.assertNoCallback();
-        vpnUidCallback.assertNoCallback();
-        vpnUidDefaultCallback.assertNoCallback();
-        vpnDefaultCallbackAsUid.assertNoCallback();
-        assertEquals(mWiFiAgent.getNetwork(), mCm.getActiveNetworkForUid(VPN_UID));
-        assertEquals(mWiFiAgent.getNetwork(), mCm.getActiveNetwork());
-        assertActiveNetworkInfo(TYPE_WIFI, DetailedState.CONNECTED);
-        assertNetworkInfo(TYPE_MOBILE, DetailedState.DISCONNECTED);
-        assertNetworkInfo(TYPE_WIFI, DetailedState.CONNECTED);
-
         // Enable lockdown and connect a VPN. The VPN is not blocked.
-        mMockVpn.setAlwaysOnPackage(ALWAYS_ON_PACKAGE, true /* lockdown */, allowList);
+        mCm.setRequireVpnForUids(true, primaryRanges);
         defaultCallback.expect(BLOCKED_STATUS, mWiFiAgent, cb -> cb.getBlocked());
         assertBlockedCallbackInAnyOrder(callback, true, mWiFiAgent, mCellAgent);
         vpnUidCallback.assertNoCallback();
@@ -10149,7 +10170,7 @@
         doAsUid(Process.SYSTEM_UID, () -> mCm.unregisterNetworkCallback(perUidCb));
     }
 
-    private VpnProfile setupLegacyLockdownVpn() {
+    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);
@@ -10158,7 +10179,9 @@
         profile.name = "My VPN";
         profile.server = "192.0.2.1";
         profile.dnsServers = "8.8.8.8";
-        profile.type = VpnProfile.TYPE_IPSEC_XAUTH_PSK;
+        profile.ipsecIdentifier = "My ipsecIdentifier";
+        profile.ipsecSecret = "My PSK";
+        profile.type = profileType;
         final byte[] encodedProfile = profile.encode();
         doReturn(encodedProfile).when(mVpnProfileStore).get(Credentials.VPN + profileName);
 
@@ -10176,8 +10199,8 @@
         mMockVpn.connect(true);
     }
 
-    @Test
-    public void testLegacyLockdownVpn() throws Exception {
+    private void doTestLockdownVpn(VpnProfile profile, boolean expectSetVpnDefaultForUids)
+            throws Exception {
         mServiceContext.setPermission(
                 Manifest.permission.CONTROL_VPN, PERMISSION_GRANTED);
 
@@ -10192,107 +10215,63 @@
         mCm.registerSystemDefaultNetworkCallback(systemDefaultCallback,
                 new Handler(ConnectivityThread.getInstanceLooper()));
 
-        // Pretend lockdown VPN was configured.
-        final VpnProfile profile = setupLegacyLockdownVpn();
-
-        // LockdownVpnTracker disables the Vpn teardown code and enables lockdown.
-        // Check the VPN's state before it does so.
-        assertTrue(mMockVpn.getEnableTeardown());
-        assertFalse(mMockVpn.getLockdown());
-
-        // VMSHandlerThread was used inside VpnManagerService and taken into LockDownVpnTracker.
-        // VpnManagerService was decoupled from this test but this handlerThread is still required
-        // in LockDownVpnTracker. Keep it until LockDownVpnTracker related verification is moved to
-        // its own test.
-        final HandlerThread VMSHandlerThread = new HandlerThread("TestVpnManagerService");
-        VMSHandlerThread.start();
-
-        // LockdownVpnTracker is created from VpnManagerService but VpnManagerService is decoupled
-        // from ConnectivityServiceTest. Create it directly to simulate LockdownVpnTracker is
-        // created.
-        // TODO: move LockdownVpnTracker related tests to its own test.
-        // Lockdown VPN disables teardown and enables lockdown.
-        final LockdownVpnTracker lockdownVpnTracker = new LockdownVpnTracker(mServiceContext,
-                VMSHandlerThread.getThreadHandler(), mMockVpn, profile);
-        lockdownVpnTracker.init();
-        assertFalse(mMockVpn.getEnableTeardown());
-        assertTrue(mMockVpn.getLockdown());
+        // Init lockdown state to simulate LockdownVpnTracker behavior.
+        mCm.setLegacyLockdownVpnEnabled(true);
+        final List<Range<Integer>> ranges =
+                intRangesPrimaryExcludingUids(Collections.EMPTY_LIST /* excludedeUids */);
+        mCm.setRequireVpnForUids(true /* requireVpn */, ranges);
 
         // Bring up a network.
-        // Expect nothing to happen because the network does not have an IPv4 default route: legacy
-        // VPN only supports IPv4.
         final LinkProperties cellLp = new LinkProperties();
         cellLp.setInterfaceName("rmnet0");
-        cellLp.addLinkAddress(new LinkAddress("2001:db8::1/64"));
-        cellLp.addRoute(new RouteInfo(new IpPrefix("::/0"), null, "rmnet0"));
-        mCellAgent = new TestNetworkAgentWrapper(TRANSPORT_CELLULAR, cellLp);
-        mCellAgent.connect(false /* validated */);
-        callback.expectAvailableCallbacksUnvalidatedAndBlocked(mCellAgent);
-        defaultCallback.expectAvailableCallbacksUnvalidatedAndBlocked(mCellAgent);
-        systemDefaultCallback.expectAvailableCallbacksUnvalidatedAndBlocked(mCellAgent);
-        waitForIdle();
-        assertNull(mMockVpn.getAgent());
-
-        // Add an IPv4 address. Ideally the VPN should start, but it doesn't because nothing calls
-        // LockdownVpnTracker#handleStateChangedLocked. This is a bug.
-        // TODO: consider fixing this.
         cellLp.addLinkAddress(new LinkAddress("192.0.2.2/25"));
         cellLp.addRoute(new RouteInfo(new IpPrefix("0.0.0.0/0"), null, "rmnet0"));
-        mCellAgent.sendLinkProperties(cellLp);
-        callback.expect(LINK_PROPERTIES_CHANGED, mCellAgent);
-        defaultCallback.expect(LINK_PROPERTIES_CHANGED, mCellAgent);
-        systemDefaultCallback.expect(LINK_PROPERTIES_CHANGED, mCellAgent);
-        waitForIdle();
-        assertNull(mMockVpn.getAgent());
-
-        // Disconnect, then try again with a network that supports IPv4 at connection time.
-        // Expect lockdown VPN to come up.
-        ExpectedBroadcast b1 = expectConnectivityAction(TYPE_MOBILE, DetailedState.DISCONNECTED);
-        mCellAgent.disconnect();
-        callback.expect(LOST, mCellAgent);
-        defaultCallback.expect(LOST, mCellAgent);
-        systemDefaultCallback.expect(LOST, mCellAgent);
-        b1.expectBroadcast();
-
         // When lockdown VPN is active, the NetworkInfo state in CONNECTIVITY_ACTION is overwritten
         // with the state of the VPN network. So expect a CONNECTING broadcast.
-        b1 = expectConnectivityAction(TYPE_MOBILE, DetailedState.CONNECTING);
+        final ExpectedBroadcast b = expectConnectivityAction(TYPE_MOBILE, DetailedState.CONNECTING);
         mCellAgent = new TestNetworkAgentWrapper(TRANSPORT_CELLULAR, cellLp);
         mCellAgent.connect(false /* validated */);
         callback.expectAvailableCallbacksUnvalidatedAndBlocked(mCellAgent);
         defaultCallback.expectAvailableCallbacksUnvalidatedAndBlocked(mCellAgent);
         systemDefaultCallback.expectAvailableCallbacksUnvalidatedAndBlocked(mCellAgent);
-        b1.expectBroadcast();
-        assertActiveNetworkInfo(TYPE_MOBILE, DetailedState.BLOCKED);
+        b.expectBroadcast();
+        // Simulate LockdownVpnTracker attempting to start the VPN since it received the
+        // systemDefault callback.
+        mMockVpn.startLegacyVpnPrivileged(profile, mCellAgent.getNetwork(), cellLp);
+        if (expectSetVpnDefaultForUids) {
+            // setVpnDefaultForUids() releases the original network request and creates a VPN
+            // request so LOST callback is received.
+            defaultCallback.expect(LOST, mCellAgent);
+            // Due to the VPN default request, getActiveNetworkInfo() gets the mNoServiceNetwork
+            // as the network satisfier.
+            assertNull(mCm.getActiveNetworkInfo());
+        } else {
+            assertActiveNetworkInfo(TYPE_MOBILE, DetailedState.BLOCKED);
+        }
         assertNetworkInfo(TYPE_MOBILE, DetailedState.BLOCKED);
         assertNetworkInfo(TYPE_WIFI, DetailedState.BLOCKED);
         assertNetworkInfo(TYPE_VPN, DetailedState.BLOCKED);
         assertExtraInfoFromCmBlocked(mCellAgent);
 
-        // TODO: it would be nice if we could simply rely on the production code here, and have
-        // LockdownVpnTracker start the VPN, have the VPN code register its NetworkAgent with
-        // ConnectivityService, etc. That would require duplicating a fair bit of code from the
-        // Vpn tests around how to mock out LegacyVpnRunner. But even if we did that, this does not
-        // work for at least two reasons:
-        // 1. In this test, calling registerNetworkAgent does not actually result in an agent being
-        //    registered. This is because nothing calls onNetworkMonitorCreated, which is what
-        //    actually ends up causing handleRegisterNetworkAgent to be called. Code in this test
-        //    that wants to register an agent must use TestNetworkAgentWrapper.
-        // 2. Even if we exposed Vpn#agentConnect to the test, and made MockVpn#agentConnect call
-        //    the TestNetworkAgentWrapper code, this would deadlock because the
-        //    TestNetworkAgentWrapper code cannot be called on the handler thread since it calls
-        //    waitForIdle().
-        mMockVpn.expectStartLegacyVpnRunner();
-        b1 = expectConnectivityAction(TYPE_VPN, DetailedState.CONNECTED);
-        ExpectedBroadcast b2 = expectConnectivityAction(TYPE_MOBILE, DetailedState.CONNECTED);
+        final ExpectedBroadcast b2 = expectConnectivityAction(TYPE_VPN, DetailedState.CONNECTED);
+        final ExpectedBroadcast b3 = expectConnectivityAction(TYPE_MOBILE, DetailedState.CONNECTED);
         establishLegacyLockdownVpn(mCellAgent.getNetwork());
         callback.expectAvailableThenValidatedCallbacks(mMockVpn);
         defaultCallback.expectAvailableThenValidatedCallbacks(mMockVpn);
         systemDefaultCallback.assertNoCallback();
-        NetworkCapabilities vpnNc = mCm.getNetworkCapabilities(mMockVpn.getNetwork());
-        b1.expectBroadcast();
+        final NetworkCapabilities vpnNc = mCm.getNetworkCapabilities(mMockVpn.getNetwork());
         b2.expectBroadcast();
-        assertActiveNetworkInfo(TYPE_MOBILE, DetailedState.CONNECTED);
+        b3.expectBroadcast();
+        if (expectSetVpnDefaultForUids) {
+            // Due to the VPN default request, getActiveNetworkInfo() gets the VPN network as the
+            // network satisfier which has TYPE_VPN.
+            assertActiveNetworkInfo(TYPE_VPN, DetailedState.CONNECTED);
+        } else {
+            // LegacyVpnRunner does not call setVpnDefaultsForUids(), which means
+            // getActiveNetworkInfo() can only return the info for the system-wide default instead.
+            // This should be fixed, but LegacyVpnRunner will be removed soon anyway.
+            assertActiveNetworkInfo(TYPE_MOBILE, DetailedState.CONNECTED);
+        }
         assertNetworkInfo(TYPE_MOBILE, DetailedState.CONNECTED);
         assertNetworkInfo(TYPE_WIFI, DetailedState.DISCONNECTED);
         assertNetworkInfo(TYPE_VPN, DetailedState.CONNECTED);
@@ -10313,53 +10292,78 @@
         wifiNc.addCapability(NET_CAPABILITY_NOT_METERED);
         mWiFiAgent = new TestNetworkAgentWrapper(TRANSPORT_WIFI, wifiLp, wifiNc);
 
-        b1 = expectConnectivityAction(TYPE_MOBILE, DetailedState.DISCONNECTED);
+        final ExpectedBroadcast b4 =
+                expectConnectivityAction(TYPE_MOBILE, DetailedState.DISCONNECTED);
         // Wifi is CONNECTING because the VPN isn't up yet.
-        b2 = expectConnectivityAction(TYPE_WIFI, DetailedState.CONNECTING);
-        ExpectedBroadcast b3 = expectConnectivityAction(TYPE_VPN, DetailedState.DISCONNECTED);
+        final ExpectedBroadcast b5 = expectConnectivityAction(TYPE_WIFI, DetailedState.CONNECTING);
         mWiFiAgent.connect(false /* validated */);
-        b1.expectBroadcast();
-        b2.expectBroadcast();
-        b3.expectBroadcast();
-        mMockVpn.expectStopVpnRunnerPrivileged();
-        mMockVpn.expectStartLegacyVpnRunner();
-
-        // TODO: why is wifi not blocked? Is it because when this callback is sent, the VPN is still
-        // connected, so the network is not considered blocked by the lockdown UID ranges? But the
-        // fact that a VPN is connected should only result in the VPN itself being unblocked, not
-        // any other network. Bug in isUidBlockedByVpn?
+        // Wifi is not blocked since VPN network is still connected.
         callback.expectAvailableCallbacksUnvalidated(mWiFiAgent);
+        defaultCallback.assertNoCallback();
+        systemDefaultCallback.expectAvailableCallbacksUnvalidated(mWiFiAgent);
+        b4.expectBroadcast();
+        b5.expectBroadcast();
+
+        // Simulate LockdownVpnTracker restarting the VPN since it received the systemDefault
+        // callback with different network.
+        final ExpectedBroadcast b6 = expectConnectivityAction(TYPE_VPN, DetailedState.DISCONNECTED);
+        mMockVpn.stopVpnRunnerPrivileged();
+        mMockVpn.startLegacyVpnPrivileged(profile, mWiFiAgent.getNetwork(), wifiLp);
+        // 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);
-        systemDefaultCallback.expectAvailableCallbacksUnvalidated(mWiFiAgent);
+        if (expectSetVpnDefaultForUids) {
+            // setVpnDefaultForUids() releases the original network request and creates a VPN
+            // request so LOST callback is received.
+            defaultCallback.expect(LOST, mWiFiAgent);
+        }
+        systemDefaultCallback.assertNoCallback();
+        b6.expectBroadcast();
 
         // While the VPN is reconnecting on the new network, everything is blocked.
-        assertActiveNetworkInfo(TYPE_WIFI, DetailedState.BLOCKED);
+        if (expectSetVpnDefaultForUids) {
+            // Due to the VPN default request, getActiveNetworkInfo() gets the mNoServiceNetwork
+            // as the network satisfier.
+            assertNull(mCm.getActiveNetworkInfo());
+        } else {
+            assertActiveNetworkInfo(TYPE_WIFI, DetailedState.BLOCKED);
+        }
         assertNetworkInfo(TYPE_MOBILE, DetailedState.BLOCKED);
         assertNetworkInfo(TYPE_WIFI, DetailedState.BLOCKED);
         assertNetworkInfo(TYPE_VPN, DetailedState.BLOCKED);
         assertExtraInfoFromCmBlocked(mWiFiAgent);
 
         // The VPN comes up again on wifi.
-        b1 = expectConnectivityAction(TYPE_VPN, DetailedState.CONNECTED);
-        b2 = expectConnectivityAction(TYPE_WIFI, DetailedState.CONNECTED);
+        final ExpectedBroadcast b7 = expectConnectivityAction(TYPE_VPN, DetailedState.CONNECTED);
+        final ExpectedBroadcast b8 = expectConnectivityAction(TYPE_WIFI, DetailedState.CONNECTED);
         establishLegacyLockdownVpn(mWiFiAgent.getNetwork());
         callback.expectAvailableThenValidatedCallbacks(mMockVpn);
         defaultCallback.expectAvailableThenValidatedCallbacks(mMockVpn);
         systemDefaultCallback.assertNoCallback();
-        b1.expectBroadcast();
-        b2.expectBroadcast();
-        assertActiveNetworkInfo(TYPE_WIFI, DetailedState.CONNECTED);
+        b7.expectBroadcast();
+        b8.expectBroadcast();
+        if (expectSetVpnDefaultForUids) {
+            // Due to the VPN default request, getActiveNetworkInfo() gets the VPN network as the
+            // network satisfier which has TYPE_VPN.
+            assertActiveNetworkInfo(TYPE_VPN, DetailedState.CONNECTED);
+        } else {
+            // LegacyVpnRunner does not call setVpnDefaultsForUids(), which means
+            // getActiveNetworkInfo() can only return the info for the system-wide default instead.
+            // This should be fixed, but LegacyVpnRunner will be removed soon anyway.
+            assertActiveNetworkInfo(TYPE_WIFI, DetailedState.CONNECTED);
+        }
         assertNetworkInfo(TYPE_MOBILE, DetailedState.DISCONNECTED);
         assertNetworkInfo(TYPE_WIFI, DetailedState.CONNECTED);
         assertNetworkInfo(TYPE_VPN, DetailedState.CONNECTED);
         assertExtraInfoFromCmPresent(mWiFiAgent);
-        vpnNc = mCm.getNetworkCapabilities(mMockVpn.getNetwork());
-        assertTrue(vpnNc.hasTransport(TRANSPORT_VPN));
-        assertTrue(vpnNc.hasTransport(TRANSPORT_WIFI));
-        assertFalse(vpnNc.hasTransport(TRANSPORT_CELLULAR));
-        assertTrue(vpnNc.hasCapability(NET_CAPABILITY_NOT_METERED));
+        final NetworkCapabilities vpnNc2 = mCm.getNetworkCapabilities(mMockVpn.getNetwork());
+        assertTrue(vpnNc2.hasTransport(TRANSPORT_VPN));
+        assertTrue(vpnNc2.hasTransport(TRANSPORT_WIFI));
+        assertFalse(vpnNc2.hasTransport(TRANSPORT_CELLULAR));
+        assertTrue(vpnNc2.hasCapability(NET_CAPABILITY_NOT_METERED));
 
         // Disconnect cell. Nothing much happens since it's not the default network.
         mCellAgent.disconnect();
@@ -10367,30 +10371,61 @@
         defaultCallback.assertNoCallback();
         systemDefaultCallback.assertNoCallback();
 
-        assertActiveNetworkInfo(TYPE_WIFI, DetailedState.CONNECTED);
+        if (expectSetVpnDefaultForUids) {
+            // Due to the VPN default request, getActiveNetworkInfo() gets the VPN network as the
+            // network satisfier which has TYPE_VPN.
+            assertActiveNetworkInfo(TYPE_VPN, DetailedState.CONNECTED);
+        } else {
+            // LegacyVpnRunner does not call setVpnDefaultsForUids(), which means
+            // getActiveNetworkInfo() can only return the info for the system-wide default instead.
+            // This should be fixed, but LegacyVpnRunner will be removed soon anyway.
+            assertActiveNetworkInfo(TYPE_WIFI, DetailedState.CONNECTED);
+        }
         assertNetworkInfo(TYPE_MOBILE, DetailedState.DISCONNECTED);
         assertNetworkInfo(TYPE_WIFI, DetailedState.CONNECTED);
         assertNetworkInfo(TYPE_VPN, DetailedState.CONNECTED);
         assertExtraInfoFromCmPresent(mWiFiAgent);
 
-        b1 = expectConnectivityAction(TYPE_WIFI, DetailedState.DISCONNECTED);
-        b2 = expectConnectivityAction(TYPE_VPN, DetailedState.DISCONNECTED);
+        final ExpectedBroadcast b9 =
+                expectConnectivityAction(TYPE_WIFI, DetailedState.DISCONNECTED);
+        final ExpectedBroadcast b10 =
+                expectConnectivityAction(TYPE_VPN, DetailedState.DISCONNECTED);
         mWiFiAgent.disconnect();
         callback.expect(LOST, mWiFiAgent);
-        systemDefaultCallback.expect(LOST, mWiFiAgent);
-        b1.expectBroadcast();
         callback.expectCaps(mMockVpn, c -> !c.hasTransport(TRANSPORT_WIFI));
-        mMockVpn.expectStopVpnRunnerPrivileged();
+        defaultCallback.expectCaps(mMockVpn, c -> !c.hasTransport(TRANSPORT_WIFI));
+        systemDefaultCallback.expect(LOST, mWiFiAgent);
+        // TODO: There should only be one LOST callback. Since the WIFI network is underlying a VPN
+        // network, ConnectivityService#propagateUnderlyingNetworkCapabilities() causes a rematch to
+        // occur. Notably, this happens before setting the satisfiers of its network requests to
+        // null. Since the satisfiers are set to null in the rematch, an extra LOST callback is
+        // called.
+        systemDefaultCallback.expect(LOST, mWiFiAgent);
+        b9.expectBroadcast();
+        mMockVpn.stopVpnRunnerPrivileged();
         callback.expect(LOST, mMockVpn);
-        b2.expectBroadcast();
+        defaultCallback.expect(LOST, mMockVpn);
+        b10.expectBroadcast();
 
-        VMSHandlerThread.quitSafely();
-        VMSHandlerThread.join();
+        assertNoCallbacks(callback, defaultCallback, systemDefaultCallback);
+    }
+
+    @Test
+    public void testLockdownVpn_LegacyVpnRunner() throws Exception {
+        final VpnProfile profile = setupLockdownVpn(VpnProfile.TYPE_IPSEC_XAUTH_PSK);
+        doTestLockdownVpn(profile, false /* expectSetVpnDefaultForUids */);
+    }
+
+    @Test
+    public void testLockdownVpn_Ikev2VpnRunner() throws Exception {
+        final VpnProfile profile = setupLockdownVpn(VpnProfile.TYPE_IKEV2_IPSEC_PSK);
+        doTestLockdownVpn(profile, true /* expectSetVpnDefaultForUids */);
     }
 
     @Test @IgnoreUpTo(Build.VERSION_CODES.S_V2)
     public void testLockdownSetFirewallUidRule() throws Exception {
-        final Set<Range<Integer>> lockdownRange = UidRange.toIntRanges(Set.of(PRIMARY_UIDRANGE));
+        final List<Range<Integer>> lockdownRange =
+                intRangesPrimaryExcludingUids(Collections.EMPTY_LIST /* excludedeUids */);
         // Enable Lockdown
         mCm.setRequireVpnForUids(true /* requireVpn */, lockdownRange);
         waitForIdle();
@@ -10498,13 +10533,11 @@
         final boolean allowlist = true;
         final boolean denylist = false;
 
-        doReturn(true).when(mBpfNetMaps).isFirewallAllowList(anyInt());
         doTestSetFirewallChainEnabledCloseSocket(FIREWALL_CHAIN_DOZABLE, allowlist);
         doTestSetFirewallChainEnabledCloseSocket(FIREWALL_CHAIN_POWERSAVE, allowlist);
         doTestSetFirewallChainEnabledCloseSocket(FIREWALL_CHAIN_RESTRICTED, allowlist);
         doTestSetFirewallChainEnabledCloseSocket(FIREWALL_CHAIN_LOW_POWER_STANDBY, allowlist);
 
-        doReturn(false).when(mBpfNetMaps).isFirewallAllowList(anyInt());
         doTestSetFirewallChainEnabledCloseSocket(FIREWALL_CHAIN_STANDBY, denylist);
         doTestSetFirewallChainEnabledCloseSocket(FIREWALL_CHAIN_OEM_DENY_1, denylist);
         doTestSetFirewallChainEnabledCloseSocket(FIREWALL_CHAIN_OEM_DENY_2, denylist);
@@ -10772,6 +10805,8 @@
         final RouteInfo ipv4Subnet = new RouteInfo(myIpv4, null, MOBILE_IFNAME);
         final RouteInfo stackedDefault =
                 new RouteInfo((IpPrefix) null, myIpv4.getAddress(), CLAT_MOBILE_IFNAME);
+        final BaseNetdUnsolicitedEventListener netdUnsolicitedListener =
+                getRegisteredNetdUnsolicitedEventListener();
 
         final NetworkRequest networkRequest = new NetworkRequest.Builder()
                 .addTransportType(TRANSPORT_CELLULAR)
@@ -10839,7 +10874,6 @@
         assertRoutesRemoved(cellNetId, ipv4Subnet);
 
         // When NAT64 prefix discovery succeeds, LinkProperties are updated and clatd is started.
-        Nat464Xlat clat = getNat464Xlat(mCellAgent);
         assertNull(mCm.getLinkProperties(mCellAgent.getNetwork()).getNat64Prefix());
         mService.mResolverUnsolEventCallback.onNat64PrefixEvent(
                 makeNat64PrefixEvent(cellNetId, PREFIX_OPERATION_ADDED, kNat64PrefixString, 96));
@@ -10850,7 +10884,8 @@
         verifyClatdStart(null /* inOrder */, MOBILE_IFNAME, cellNetId, kNat64Prefix.toString());
 
         // Clat iface comes up. Expect stacked link to be added.
-        clat.interfaceLinkStateChanged(CLAT_MOBILE_IFNAME, true);
+        netdUnsolicitedListener.onInterfaceLinkStateChanged(
+                CLAT_MOBILE_IFNAME, true);
         networkCallback.expect(LINK_PROPERTIES_CHANGED, mCellAgent);
         List<LinkProperties> stackedLps = mCm.getLinkProperties(mCellAgent.getNetwork())
                 .getStackedLinks();
@@ -10896,7 +10931,7 @@
                 kOtherNat64Prefix.toString());
         networkCallback.expect(LINK_PROPERTIES_CHANGED, mCellAgent,
                 cb -> cb.getLp().getNat64Prefix().equals(kOtherNat64Prefix));
-        clat.interfaceLinkStateChanged(CLAT_MOBILE_IFNAME, true);
+        netdUnsolicitedListener.onInterfaceLinkStateChanged(CLAT_MOBILE_IFNAME, true);
         networkCallback.expect(LINK_PROPERTIES_CHANGED, mCellAgent,
                 cb -> cb.getLp().getStackedLinks().size() == 1);
         assertRoutesAdded(cellNetId, stackedDefault);
@@ -10924,7 +10959,7 @@
         assertRoutesRemoved(cellNetId, stackedDefault);
 
         // The interface removed callback happens but has no effect after stop is called.
-        clat.interfaceRemoved(CLAT_MOBILE_IFNAME);
+        netdUnsolicitedListener.onInterfaceRemoved(CLAT_MOBILE_IFNAME);
         networkCallback.assertNoCallback();
         verify(mMockNetd, times(1)).networkRemoveInterface(cellNetId, CLAT_MOBILE_IFNAME);
 
@@ -10961,7 +10996,7 @@
         verifyClatdStart(null /* inOrder */, MOBILE_IFNAME, cellNetId, kNat64Prefix.toString());
 
         // Clat iface comes up. Expect stacked link to be added.
-        clat.interfaceLinkStateChanged(CLAT_MOBILE_IFNAME, true);
+        netdUnsolicitedListener.onInterfaceLinkStateChanged(CLAT_MOBILE_IFNAME, true);
         networkCallback.expect(LINK_PROPERTIES_CHANGED, mCellAgent,
                 cb -> cb.getLp().getStackedLinks().size() == 1
                         && cb.getLp().getNat64Prefix() != null);
@@ -11029,8 +11064,7 @@
 
         // Clatd is started and clat iface comes up. Expect stacked link to be added.
         verifyClatdStart(null /* inOrder */, MOBILE_IFNAME, cellNetId, kNat64Prefix.toString());
-        clat = getNat464Xlat(mCellAgent);
-        clat.interfaceLinkStateChanged(CLAT_MOBILE_IFNAME, true /* up */);
+        netdUnsolicitedListener.onInterfaceLinkStateChanged(CLAT_MOBILE_IFNAME, true /* up */);
         networkCallback.expect(LINK_PROPERTIES_CHANGED, mCellAgent,
                 cb -> cb.getLp().getStackedLinks().size() == 1
                         && cb.getLp().getNat64Prefix().equals(kNat64Prefix));
@@ -12508,9 +12542,6 @@
         mMockVpn.establish(new LinkProperties(), vpnOwnerUid, vpnRange);
         assertVpnUidRangesUpdated(true, vpnRange, vpnOwnerUid);
 
-        final UnderlyingNetworkInfo underlyingNetworkInfo =
-                new UnderlyingNetworkInfo(vpnOwnerUid, VPN_IFNAME, new ArrayList<>());
-        mMockVpn.setUnderlyingNetworkInfo(underlyingNetworkInfo);
         mDeps.setConnectionOwnerUid(42);
     }
 
@@ -12754,7 +12785,8 @@
 
     private NetworkAgentInfo fakeNai(NetworkCapabilities nc, NetworkInfo networkInfo) {
         return new NetworkAgentInfo(null, new Network(NET_ID), networkInfo, new LinkProperties(),
-                nc, new NetworkScore.Builder().setLegacyInt(0).build(),
+                nc, null /* localNetworkConfig */,
+                new NetworkScore.Builder().setLegacyInt(0).build(),
                 mServiceContext, null, new NetworkAgentConfig(), mService, null, null, 0,
                 INVALID_UID, TEST_LINGER_DELAY_MS, mQosCallbackTracker,
                 new ConnectivityService.Dependencies());
@@ -13146,7 +13178,7 @@
     }
 
     @Test
-    public void testDumpDoesNotCrash() {
+    public void testDumpDoesNotCrash() throws Exception {
         mServiceContext.setPermission(DUMP, PERMISSION_GRANTED);
         // Filing a couple requests prior to testing the dump.
         final TestNetworkCallback genericNetworkCallback = new TestNetworkCallback();
@@ -13158,6 +13190,44 @@
         mCm.registerNetworkCallback(genericRequest, genericNetworkCallback);
         mCm.registerNetworkCallback(wifiRequest, wifiNetworkCallback);
 
+        // NetworkProvider
+        final NetworkProvider wifiProvider = new NetworkProvider(mServiceContext,
+                mCsHandlerThread.getLooper(), "Wifi provider");
+        mCm.registerNetworkProvider(wifiProvider);
+
+        // NetworkAgent
+        final LinkProperties wifiLp = new LinkProperties();
+        wifiLp.setInterfaceName(WIFI_IFNAME);
+        mWiFiAgent = new TestNetworkAgentWrapper(TRANSPORT_WIFI, wifiLp);
+        mWiFiAgent.connect(true);
+
+        // NetworkOffer
+        final NetworkScore wifiScore = new NetworkScore.Builder().build();
+        final NetworkCapabilities wifiCaps = new NetworkCapabilities.Builder()
+                .addTransportType(TRANSPORT_WIFI)
+                .addCapability(NET_CAPABILITY_INTERNET)
+                .addCapability(NET_CAPABILITY_NOT_VCN_MANAGED)
+                .build();
+        final TestableNetworkOfferCallback wifiCallback = new TestableNetworkOfferCallback(
+                TIMEOUT_MS /* timeout */, TEST_CALLBACK_TIMEOUT_MS /* noCallbackTimeout */);
+        wifiProvider.registerNetworkOffer(wifiScore, wifiCaps, r -> r.run(), wifiCallback);
+
+        // Profile preferences
+        final UserHandle testHandle = setupEnterpriseNetwork();
+        final TestNetworkAgentWrapper workAgent = makeEnterpriseNetworkAgent();
+        workAgent.connect(true);
+        mCm.setProfileNetworkPreference(testHandle, PROFILE_NETWORK_PREFERENCE_ENTERPRISE,
+                null /* executor */, null /* listener */);
+
+        // OEM preferences
+        @OemNetworkPreferences.OemNetworkPreference final int networkPref =
+                OEM_NETWORK_PREFERENCE_OEM_PAID;
+        setOemNetworkPreferenceAgentConnected(TRANSPORT_CELLULAR, true);
+        setOemNetworkPreference(networkPref, TEST_PACKAGE_NAME);
+
+        // Mobile data preferred UIDs
+        setAndUpdateMobileDataPreferredUids(Set.of(TEST_PACKAGE_UID));
+
         verifyDump(new String[0]);
 
         // Verify dump with arguments.
@@ -15251,6 +15321,8 @@
         expectNoRequestChanged(oemPaidFactory);
         internetFactory.expectRequestAdd();
         mCm.unregisterNetworkCallback(wifiCallback);
+        handlerThread.quitSafely();
+        handlerThread.join();
     }
 
     /**
@@ -15615,6 +15687,8 @@
             assertTrue(testFactory.getMyStartRequested());
         } finally {
             testFactory.terminate();
+            handlerThread.quitSafely();
+            handlerThread.join();
         }
     }
 
diff --git a/tests/unit/java/com/android/server/HandlerUtilsTest.kt b/tests/unit/java/com/android/server/HandlerUtilsTest.kt
new file mode 100644
index 0000000..62bb651
--- /dev/null
+++ b/tests/unit/java/com/android/server/HandlerUtilsTest.kt
@@ -0,0 +1,58 @@
+/*
+ * 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.HandlerThread
+import com.android.server.connectivity.HandlerUtils
+import com.android.testutils.DevSdkIgnoreRunner
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+import org.junit.After
+import org.junit.Test
+import org.junit.runner.RunWith
+
+const val THREAD_BLOCK_TIMEOUT_MS = 1000L
+const val TEST_REPEAT_COUNT = 100
+@RunWith(DevSdkIgnoreRunner::class)
+class HandlerUtilsTest {
+    val handlerThread = HandlerThread("HandlerUtilsTestHandlerThread").also {
+        it.start()
+    }
+    val handler = handlerThread.threadHandler
+
+    @Test
+    fun testRunWithScissors() {
+        // Repeat the test a fair amount of times to ensure that it does not pass by chance.
+        repeat(TEST_REPEAT_COUNT) {
+            var result = false
+            HandlerUtils.runWithScissors(handler, {
+                assertEquals(Thread.currentThread(), handlerThread)
+                result = true
+            }, THREAD_BLOCK_TIMEOUT_MS)
+            // Assert that the result is modified on the handler thread, but can also be seen from
+            // the current thread. The assertion should pass if the runWithScissors provides
+            // the guarantee where the assignment happens-before the assertion.
+            assertTrue(result)
+        }
+    }
+
+    @After
+    fun tearDown() {
+        handlerThread.quitSafely()
+        handlerThread.join()
+    }
+}
diff --git a/tests/unit/java/com/android/server/NsdServiceTest.java b/tests/unit/java/com/android/server/NsdServiceTest.java
index 71bd330..771edb2 100644
--- a/tests/unit/java/com/android/server/NsdServiceTest.java
+++ b/tests/unit/java/com/android/server/NsdServiceTest.java
@@ -35,14 +35,17 @@
 import static android.net.nsd.NsdManager.FAILURE_BAD_PARAMETERS;
 import static android.net.nsd.NsdManager.FAILURE_INTERNAL_ERROR;
 import static android.net.nsd.NsdManager.FAILURE_OPERATION_NOT_RUNNING;
+
 import static com.android.networkstack.apishim.api33.ConstantsShim.REGISTER_NSD_OFFLOAD_ENGINE;
 import static com.android.server.NsdService.DEFAULT_RUNNING_APP_ACTIVE_IMPORTANCE_CUTOFF;
 import static com.android.server.NsdService.MdnsListener;
 import static com.android.server.NsdService.NO_TRANSACTION;
 import static com.android.server.NsdService.parseTypeAndSubtype;
 import static com.android.testutils.ContextUtils.mockService;
+
 import static libcore.junit.util.compat.CoreCompatChangeRule.DisableCompatChanges;
 import static libcore.junit.util.compat.CoreCompatChangeRule.EnableCompatChanges;
+
 import static org.junit.Assert.assertArrayEquals;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertNotNull;
@@ -220,7 +223,7 @@
                 anyInt(), anyString(), anyString(), anyString(), anyInt());
         doReturn(false).when(mDeps).isMdnsDiscoveryManagerEnabled(any(Context.class));
         doReturn(mDiscoveryManager).when(mDeps)
-                .makeMdnsDiscoveryManager(any(), any(), any());
+                .makeMdnsDiscoveryManager(any(), any(), any(), any());
         doReturn(mMulticastLock).when(mWifiManager).createMulticastLock(any());
         doReturn(mSocketProvider).when(mDeps).makeMdnsSocketProvider(any(), any(), any(), any());
         doReturn(DEFAULT_RUNNING_APP_ACTIVE_IMPORTANCE_CUTOFF).when(mDeps).getDeviceConfigInt(
diff --git a/tests/unit/java/com/android/server/connectivity/AutomaticOnOffKeepaliveTrackerTest.java b/tests/unit/java/com/android/server/connectivity/AutomaticOnOffKeepaliveTrackerTest.java
index 986c389..8e19c01 100644
--- a/tests/unit/java/com/android/server/connectivity/AutomaticOnOffKeepaliveTrackerTest.java
+++ b/tests/unit/java/com/android/server/connectivity/AutomaticOnOffKeepaliveTrackerTest.java
@@ -77,6 +77,7 @@
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 
+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;
@@ -94,6 +95,7 @@
 import org.mockito.MockitoAnnotations;
 
 import java.io.FileDescriptor;
+import java.io.StringWriter;
 import java.net.Inet4Address;
 import java.net.InetAddress;
 import java.net.Socket;
@@ -974,4 +976,19 @@
         // The keepalive should be removed in AutomaticOnOffKeepaliveTracker.
         assertNull(getAutoKiForBinder(testInfo.binder));
     }
+
+    @Test
+    public void testDumpDoesNotCrash() throws Exception {
+        final TestKeepaliveInfo testInfo1 = doStartNattKeepalive();
+        final TestKeepaliveInfo testInfo2 = doStartNattKeepalive();
+        checkAndProcessKeepaliveStart(TEST_SLOT, testInfo1.kpd);
+        checkAndProcessKeepaliveStart(TEST_SLOT + 1, testInfo2.kpd);
+        final AutomaticOnOffKeepalive autoKi1  = getAutoKiForBinder(testInfo1.binder);
+        doPauseKeepalive(autoKi1);
+
+        final StringWriter stringWriter = new StringWriter();
+        final IndentingPrintWriter pw = new IndentingPrintWriter(stringWriter, "   ");
+        visibleOnHandlerThread(mTestHandler, () -> mAOOKeepaliveTracker.dump(pw));
+        assertFalse(stringWriter.toString().isEmpty());
+    }
 }
diff --git a/tests/unit/java/com/android/server/connectivity/CarrierPrivilegeAuthenticatorTest.java b/tests/unit/java/com/android/server/connectivity/CarrierPrivilegeAuthenticatorTest.java
index 3849e49..8113626 100644
--- a/tests/unit/java/com/android/server/connectivity/CarrierPrivilegeAuthenticatorTest.java
+++ b/tests/unit/java/com/android/server/connectivity/CarrierPrivilegeAuthenticatorTest.java
@@ -20,12 +20,15 @@
 import static android.net.NetworkCapabilities.TRANSPORT_WIFI;
 import static android.telephony.TelephonyManager.ACTION_MULTI_SIM_CONFIG_CHANGED;
 
+import static com.android.server.connectivity.ConnectivityFlags.CARRIER_SERVICE_CHANGED_USE_CALLBACK;
+
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertTrue;
 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.atLeastOnce;
 import static org.mockito.Mockito.clearInvocations;
@@ -34,9 +37,9 @@
 import static org.mockito.Mockito.verify;
 
 import android.annotation.NonNull;
+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.net.NetworkCapabilities;
@@ -49,14 +52,17 @@
 import com.android.networkstack.apishim.TelephonyManagerShimImpl;
 import com.android.networkstack.apishim.common.TelephonyManagerShim.CarrierPrivilegesListenerShim;
 import com.android.networkstack.apishim.common.UnsupportedApiLevelException;
+import com.android.server.ConnectivityService;
 import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo;
 import com.android.testutils.DevSdkIgnoreRunner;
 
-import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
 import org.mockito.ArgumentCaptor;
 
+import java.util.Arrays;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.Map;
 
@@ -79,11 +85,13 @@
     @NonNull private TestCarrierPrivilegeAuthenticator mCarrierPrivilegeAuthenticator;
     private final int mCarrierConfigPkgUid = 12345;
     private final String mTestPkg = "com.android.server.connectivity.test";
+    private final BroadcastReceiver mMultiSimBroadcastReceiver;
 
     public class TestCarrierPrivilegeAuthenticator extends CarrierPrivilegeAuthenticator {
         TestCarrierPrivilegeAuthenticator(@NonNull final Context c,
+                @NonNull final ConnectivityService.Dependencies deps,
                 @NonNull final TelephonyManager t) {
-            super(c, t, mTelephonyManagerShim);
+            super(c, deps, t, mTelephonyManagerShim);
         }
         @Override
         protected int getSlotIndex(int subId) {
@@ -92,15 +100,20 @@
         }
     }
 
-    public CarrierPrivilegeAuthenticatorTest() {
+    /** Parameters to test both using callbacks or the old broadcast */
+    @Parameterized.Parameters
+    public static Collection<Boolean> shouldUseCallbacks() {
+        return Arrays.asList(true, false);
+    }
+
+    public CarrierPrivilegeAuthenticatorTest(final boolean useCallbacks) throws Exception {
         mContext = mock(Context.class);
         mTelephonyManager = mock(TelephonyManager.class);
         mTelephonyManagerShim = mock(TelephonyManagerShimImpl.class);
         mPackageManager = mock(PackageManager.class);
-    }
-
-    @Before
-    public void setUp() throws Exception {
+        final ConnectivityService.Dependencies deps = mock(ConnectivityService.Dependencies.class);
+        doReturn(useCallbacks).when(deps).isFeatureEnabled(any() /* context */,
+                eq(CARRIER_SERVICE_CHANGED_USE_CALLBACK));
         doReturn(SUBSCRIPTION_COUNT).when(mTelephonyManager).getActiveModemCount();
         doReturn(mTestPkg).when(mTelephonyManagerShim)
                 .getCarrierServicePackageNameForLogicalSlot(anyInt());
@@ -109,13 +122,13 @@
         applicationInfo.uid = mCarrierConfigPkgUid;
         doReturn(applicationInfo).when(mPackageManager).getApplicationInfo(eq(mTestPkg), anyInt());
         mCarrierPrivilegeAuthenticator =
-                new TestCarrierPrivilegeAuthenticator(mContext, mTelephonyManager);
-    }
-
-    private IntentFilter getIntentFilter() {
-        final ArgumentCaptor<IntentFilter> captor = ArgumentCaptor.forClass(IntentFilter.class);
-        verify(mContext).registerReceiver(any(), captor.capture(), any(), any());
-        return captor.getValue();
+                new TestCarrierPrivilegeAuthenticator(mContext, deps, mTelephonyManager);
+        final ArgumentCaptor<BroadcastReceiver> receiverCaptor =
+                ArgumentCaptor.forClass(BroadcastReceiver.class);
+        verify(mContext).registerReceiver(receiverCaptor.capture(), argThat(filter ->
+                filter.getAction(0).equals(ACTION_MULTI_SIM_CONFIG_CHANGED)
+        ), any() /* broadcast permissions */, any() /* handler */);
+        mMultiSimBroadcastReceiver = receiverCaptor.getValue();
     }
 
     private Map<Integer, CarrierPrivilegesListenerShim> getCarrierPrivilegesListeners() {
@@ -138,15 +151,6 @@
     }
     @Test
     public void testConstructor() throws Exception {
-        verify(mContext).registerReceiver(
-                        eq(mCarrierPrivilegeAuthenticator),
-                        any(IntentFilter.class),
-                        any(),
-                        any());
-        final IntentFilter filter = getIntentFilter();
-        assertEquals(1, filter.countActions());
-        assertTrue(filter.hasAction(ACTION_MULTI_SIM_CONFIG_CHANGED));
-
         // Two listeners originally registered, one for slot 0 and one for slot 1
         final Map<Integer, CarrierPrivilegesListenerShim> initialListeners =
                 getCarrierPrivilegesListeners();
@@ -154,6 +158,8 @@
         assertNotNull(initialListeners.get(1));
         assertEquals(2, initialListeners.size());
 
+        initialListeners.get(0).onCarrierServiceChanged(null, mCarrierConfigPkgUid);
+
         final NetworkCapabilities.Builder ncBuilder = new NetworkCapabilities.Builder()
                 .addTransportType(TRANSPORT_CELLULAR)
                 .setNetworkSpecifier(new TelephonyNetworkSpecifier(0));
@@ -174,8 +180,11 @@
         assertEquals(2, initialListeners.size());
 
         doReturn(1).when(mTelephonyManager).getActiveModemCount();
-        mCarrierPrivilegeAuthenticator.onReceive(
-                mContext, buildTestMultiSimConfigBroadcastIntent());
+
+        // 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());
         // Check all listeners have been removed
         for (CarrierPrivilegesListenerShim listener : initialListeners.values()) {
             verify(mTelephonyManagerShim).removeCarrierPrivilegesListener(eq(listener));
@@ -187,6 +196,8 @@
         assertNotNull(newListeners.get(0));
         assertEquals(1, newListeners.size());
 
+        newListeners.get(0).onCarrierServiceChanged(null, mCarrierConfigPkgUid);
+
         final TelephonyNetworkSpecifier specifier = new TelephonyNetworkSpecifier(0);
         final NetworkCapabilities nc = new NetworkCapabilities.Builder()
                 .addTransportType(TRANSPORT_CELLULAR)
@@ -212,6 +223,7 @@
         applicationInfo.uid = mCarrierConfigPkgUid + 1;
         doReturn(applicationInfo).when(mPackageManager).getApplicationInfo(eq(mTestPkg), anyInt());
         listener.onCarrierPrivilegesChanged(Collections.emptyList(), new int[] {});
+        listener.onCarrierServiceChanged(null, applicationInfo.uid);
 
         assertFalse(mCarrierPrivilegeAuthenticator.hasCarrierPrivilegeForNetworkCapabilities(
                 mCarrierConfigPkgUid, nc));
@@ -221,6 +233,9 @@
 
     @Test
     public void testDefaultSubscription() throws Exception {
+        final CarrierPrivilegesListenerShim listener = getCarrierPrivilegesListeners().get(0);
+        listener.onCarrierServiceChanged(null, mCarrierConfigPkgUid);
+
         final NetworkCapabilities.Builder ncBuilder = new NetworkCapabilities.Builder();
         ncBuilder.addTransportType(TRANSPORT_CELLULAR);
         assertFalse(mCarrierPrivilegeAuthenticator.hasCarrierPrivilegeForNetworkCapabilities(
diff --git a/tests/unit/java/com/android/server/connectivity/ClatCoordinatorTest.java b/tests/unit/java/com/android/server/connectivity/ClatCoordinatorTest.java
index 4158663..88044be 100644
--- a/tests/unit/java/com/android/server/connectivity/ClatCoordinatorTest.java
+++ b/tests/unit/java/com/android/server/connectivity/ClatCoordinatorTest.java
@@ -508,10 +508,10 @@
         // Expected mtu is that the detected mtu minus MTU_DELTA(28).
         assertEquals(1372, ClatCoordinator.adjustMtu(1400));
         assertEquals(1472, ClatCoordinator.adjustMtu(ETHER_MTU));
-        assertEquals(65508, ClatCoordinator.adjustMtu(CLAT_MAX_MTU));
+        assertEquals(1500, ClatCoordinator.adjustMtu(CLAT_MAX_MTU));
 
-        // Expected mtu is that CLAT_MAX_MTU(65536) minus MTU_DELTA(28).
-        assertEquals(65508, ClatCoordinator.adjustMtu(CLAT_MAX_MTU + 1 /* over maximum mtu */));
+        // Expected mtu is that CLAT_MAX_MTU(1528) minus MTU_DELTA(28).
+        assertEquals(1500, ClatCoordinator.adjustMtu(CLAT_MAX_MTU + 1 /* over maximum mtu */));
     }
 
     private void verifyDump(final ClatCoordinator coordinator, boolean clatStarted) {
diff --git a/tests/unit/java/com/android/server/connectivity/DnsManagerTest.java b/tests/unit/java/com/android/server/connectivity/DnsManagerTest.java
index 24aecdb..545ed16 100644
--- a/tests/unit/java/com/android/server/connectivity/DnsManagerTest.java
+++ b/tests/unit/java/com/android/server/connectivity/DnsManagerTest.java
@@ -139,7 +139,8 @@
         assertEquals(actual.tlsConnectTimeoutMs, expected.tlsConnectTimeoutMs);
         assertResolverOptionsEquals(actual.resolverOptions, expected.resolverOptions);
         assertContainsExactly(actual.transportTypes, expected.transportTypes);
-        assertFieldCountEquals(16, ResolverParamsParcel.class);
+        assertEquals(actual.meteredNetwork, expected.meteredNetwork);
+        assertFieldCountEquals(17, ResolverParamsParcel.class);
     }
 
     @Before
@@ -169,10 +170,12 @@
         lp.addDnsServer(InetAddress.getByName("4.4.4.4"));
 
         // Send a validation event that is tracked on the alternate netId
-        mDnsManager.updateTransportsForNetwork(TEST_NETID, TEST_TRANSPORT_TYPES);
+        final NetworkCapabilities nc = new NetworkCapabilities();
+        nc.setTransportTypes(TEST_TRANSPORT_TYPES);
+        mDnsManager.updateCapabilitiesForNetwork(TEST_NETID, nc);
         mDnsManager.noteDnsServersForNetwork(TEST_NETID, lp);
         mDnsManager.flushVmDnsCache();
-        mDnsManager.updateTransportsForNetwork(TEST_NETID_ALTERNATE, TEST_TRANSPORT_TYPES);
+        mDnsManager.updateCapabilitiesForNetwork(TEST_NETID_ALTERNATE, nc);
         mDnsManager.noteDnsServersForNetwork(TEST_NETID_ALTERNATE, lp);
         mDnsManager.flushVmDnsCache();
         mDnsManager.updatePrivateDnsValidation(
@@ -205,7 +208,7 @@
                     InetAddress.parseNumericAddress("6.6.6.6"),
                     InetAddress.parseNumericAddress("2001:db8:66:66::1")
                     }));
-        mDnsManager.updateTransportsForNetwork(TEST_NETID, TEST_TRANSPORT_TYPES);
+        mDnsManager.updateCapabilitiesForNetwork(TEST_NETID, nc);
         mDnsManager.noteDnsServersForNetwork(TEST_NETID, lp);
         mDnsManager.flushVmDnsCache();
         fixedLp = new LinkProperties(lp);
@@ -242,7 +245,9 @@
         // be tracked.
         LinkProperties lp = new LinkProperties();
         lp.addDnsServer(InetAddress.getByName("3.3.3.3"));
-        mDnsManager.updateTransportsForNetwork(TEST_NETID, TEST_TRANSPORT_TYPES);
+        final NetworkCapabilities nc = new NetworkCapabilities();
+        nc.setTransportTypes(TEST_TRANSPORT_TYPES);
+        mDnsManager.updateCapabilitiesForNetwork(TEST_NETID, nc);
         mDnsManager.noteDnsServersForNetwork(TEST_NETID, lp);
         mDnsManager.flushVmDnsCache();
         mDnsManager.updatePrivateDnsValidation(
@@ -256,7 +261,7 @@
         // Validation event has untracked netId
         mDnsManager.updatePrivateDns(new Network(TEST_NETID),
                 mDnsManager.getPrivateDnsConfig());
-        mDnsManager.updateTransportsForNetwork(TEST_NETID, TEST_TRANSPORT_TYPES);
+        mDnsManager.updateCapabilitiesForNetwork(TEST_NETID, nc);
         mDnsManager.noteDnsServersForNetwork(TEST_NETID, lp);
         mDnsManager.flushVmDnsCache();
         mDnsManager.updatePrivateDnsValidation(
@@ -307,7 +312,7 @@
         ConnectivitySettingsManager.setPrivateDnsMode(mCtx, PRIVATE_DNS_MODE_OFF);
         mDnsManager.updatePrivateDns(new Network(TEST_NETID),
                 mDnsManager.getPrivateDnsConfig());
-        mDnsManager.updateTransportsForNetwork(TEST_NETID, TEST_TRANSPORT_TYPES);
+        mDnsManager.updateCapabilitiesForNetwork(TEST_NETID, nc);
         mDnsManager.noteDnsServersForNetwork(TEST_NETID, lp);
         mDnsManager.flushVmDnsCache();
         mDnsManager.updatePrivateDnsValidation(
@@ -352,7 +357,9 @@
         lp.setInterfaceName(TEST_IFACENAME);
         lp.addDnsServer(InetAddress.getByName("3.3.3.3"));
         lp.addDnsServer(InetAddress.getByName("4.4.4.4"));
-        mDnsManager.updateTransportsForNetwork(TEST_NETID, TEST_TRANSPORT_TYPES);
+        final NetworkCapabilities nc = new NetworkCapabilities();
+        nc.setTransportTypes(TEST_TRANSPORT_TYPES);
+        mDnsManager.updateCapabilitiesForNetwork(TEST_NETID, nc);
         mDnsManager.noteDnsServersForNetwork(TEST_NETID, lp);
         mDnsManager.flushVmDnsCache();
 
@@ -373,6 +380,7 @@
         expectedParams.tlsServers = new String[]{"3.3.3.3", "4.4.4.4"};
         expectedParams.transportTypes = TEST_TRANSPORT_TYPES;
         expectedParams.resolverOptions = null;
+        expectedParams.meteredNetwork = true;
         assertResolverParamsEquals(actualParams, expectedParams);
     }
 
diff --git a/tests/unit/java/com/android/server/connectivity/KeepaliveStatsTrackerTest.java b/tests/unit/java/com/android/server/connectivity/KeepaliveStatsTrackerTest.java
index 90a0edd..1b964e2 100644
--- a/tests/unit/java/com/android/server/connectivity/KeepaliveStatsTrackerTest.java
+++ b/tests/unit/java/com/android/server/connectivity/KeepaliveStatsTrackerTest.java
@@ -37,6 +37,7 @@
 import static org.mockito.Mockito.doReturn;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 
 import android.content.BroadcastReceiver;
@@ -1293,5 +1294,18 @@
                 expectRegisteredDurations,
                 expectActiveDurations,
                 new KeepaliveCarrierStats[0]);
+
+        assertTrue(mKeepaliveStatsTracker.allMetricsExpected(dailyKeepaliveInfoReported));
+
+        // Write time after 26 hours.
+        final int writeTime2 = 26 * 60 * 60 * 1000;
+        setElapsedRealtime(writeTime2);
+
+        visibleOnHandlerThread(mTestHandler, () -> mKeepaliveStatsTracker.writeAndResetMetrics());
+        verify(mDependencies, times(2)).writeStats(dailyKeepaliveInfoReportedCaptor.capture());
+        final DailykeepaliveInfoReported dailyKeepaliveInfoReported2 =
+                dailyKeepaliveInfoReportedCaptor.getValue();
+
+        assertFalse(mKeepaliveStatsTracker.allMetricsExpected(dailyKeepaliveInfoReported2));
     }
 }
diff --git a/tests/unit/java/com/android/server/connectivity/LingerMonitorTest.java b/tests/unit/java/com/android/server/connectivity/LingerMonitorTest.java
index e6c0c83..07883ff 100644
--- a/tests/unit/java/com/android/server/connectivity/LingerMonitorTest.java
+++ b/tests/unit/java/com/android/server/connectivity/LingerMonitorTest.java
@@ -372,9 +372,10 @@
         caps.addCapability(0);
         caps.addTransportType(transport);
         NetworkAgentInfo nai = new NetworkAgentInfo(null, new Network(netId), info,
-                new LinkProperties(), caps, new NetworkScore.Builder().setLegacyInt(50).build(),
-                mCtx, null, new NetworkAgentConfig.Builder().build(), mConnService, mNetd,
-                mDnsResolver, NetworkProvider.ID_NONE, Binder.getCallingUid(), TEST_LINGER_DELAY_MS,
+                new LinkProperties(), caps, null /* localNetworkConfiguration */,
+                new NetworkScore.Builder().setLegacyInt(50).build(), mCtx, null,
+                new NetworkAgentConfig.Builder().build(), mConnService, mNetd, mDnsResolver,
+                NetworkProvider.ID_NONE, Binder.getCallingUid(), TEST_LINGER_DELAY_MS,
                 mQosCallbackTracker, new ConnectivityService.Dependencies());
         if (setEverValidated) {
             // As tests in this class deal with testing lingering, most tests are interested
diff --git a/tests/unit/java/com/android/server/connectivity/Nat464XlatTest.java b/tests/unit/java/com/android/server/connectivity/Nat464XlatTest.java
index 58c0114..2fe8713 100644
--- a/tests/unit/java/com/android/server/connectivity/Nat464XlatTest.java
+++ b/tests/unit/java/com/android/server/connectivity/Nat464XlatTest.java
@@ -86,7 +86,6 @@
     @Mock ClatCoordinator mClatCoordinator;
 
     TestLooper mLooper;
-    Handler mHandler;
     NetworkAgentConfig mAgentConfig = new NetworkAgentConfig();
 
     Nat464Xlat makeNat464Xlat(boolean isCellular464XlatEnabled) {
@@ -96,6 +95,14 @@
             }
         };
 
+        // The test looper needs to be created here on the test case thread and not in setUp,
+        // because setUp and test cases are run in different threads. Creating the test looper in
+        // setUp would make Looper.getThread() return the setUp thread, which does not match the
+        // test case thread that is actually used to process the messages.
+        mLooper = new TestLooper();
+        final Handler handler = new Handler(mLooper.getLooper());
+        doReturn(handler).when(mNai).handler();
+
         return new Nat464Xlat(mNai, mNetd, mDnsResolver, deps) {
             @Override protected int getNetId() {
                 return NETID;
@@ -117,9 +124,6 @@
 
     @Before
     public void setUp() throws Exception {
-        mLooper = new TestLooper();
-        mHandler = new Handler(mLooper.getLooper());
-
         MockitoAnnotations.initMocks(this);
 
         mNai.linkProperties = new LinkProperties();
@@ -130,7 +134,6 @@
         markNetworkConnected();
         when(mNai.connService()).thenReturn(mConnectivity);
         when(mNai.netAgentConfig()).thenReturn(mAgentConfig);
-        when(mNai.handler()).thenReturn(mHandler);
         final InterfaceConfigurationParcel mConfig = new InterfaceConfigurationParcel();
         when(mNetd.interfaceGetCfg(eq(STACKED_IFACE))).thenReturn(mConfig);
         mConfig.ipv4Addr = ADDR.getAddress().getHostAddress();
@@ -272,8 +275,7 @@
         verifyClatdStart(null /* inOrder */);
 
         // Stacked interface up notification arrives.
-        nat.interfaceLinkStateChanged(STACKED_IFACE, true);
-        mLooper.dispatchNext();
+        nat.handleInterfaceLinkStateChanged(STACKED_IFACE, true);
 
         verify(mNetd).interfaceGetCfg(eq(STACKED_IFACE));
         verify(mConnectivity).handleUpdateLinkProperties(eq(mNai), c.capture());
@@ -294,8 +296,7 @@
         // Verify the generated v6 is reset when clat is stopped.
         assertNull(nat.mIPv6Address);
         // Stacked interface removed notification arrives and is ignored.
-        nat.interfaceRemoved(STACKED_IFACE);
-        mLooper.dispatchNext();
+        nat.handleInterfaceRemoved(STACKED_IFACE);
 
         verifyNoMoreInteractions(mNetd, mConnectivity);
     }
@@ -324,8 +325,7 @@
         verifyClatdStart(inOrder);
 
         // Stacked interface up notification arrives.
-        nat.interfaceLinkStateChanged(STACKED_IFACE, true);
-        mLooper.dispatchNext();
+        nat.handleInterfaceLinkStateChanged(STACKED_IFACE, true);
 
         inOrder.verify(mConnectivity).handleUpdateLinkProperties(eq(mNai), c.capture());
         assertFalse(c.getValue().getStackedLinks().isEmpty());
@@ -344,10 +344,8 @@
 
         if (interfaceRemovedFirst) {
             // Stacked interface removed notification arrives and is ignored.
-            nat.interfaceRemoved(STACKED_IFACE);
-            mLooper.dispatchNext();
-            nat.interfaceLinkStateChanged(STACKED_IFACE, false);
-            mLooper.dispatchNext();
+            nat.handleInterfaceRemoved(STACKED_IFACE);
+            nat.handleInterfaceLinkStateChanged(STACKED_IFACE, false);
         }
 
         assertTrue(c.getValue().getStackedLinks().isEmpty());
@@ -361,15 +359,12 @@
 
         if (!interfaceRemovedFirst) {
             // Stacked interface removed notification arrives and is ignored.
-            nat.interfaceRemoved(STACKED_IFACE);
-            mLooper.dispatchNext();
-            nat.interfaceLinkStateChanged(STACKED_IFACE, false);
-            mLooper.dispatchNext();
+            nat.handleInterfaceRemoved(STACKED_IFACE);
+            nat.handleInterfaceLinkStateChanged(STACKED_IFACE, false);
         }
 
         // Stacked interface up notification arrives.
-        nat.interfaceLinkStateChanged(STACKED_IFACE, true);
-        mLooper.dispatchNext();
+        nat.handleInterfaceLinkStateChanged(STACKED_IFACE, true);
 
         inOrder.verify(mConnectivity).handleUpdateLinkProperties(eq(mNai), c.capture());
         assertFalse(c.getValue().getStackedLinks().isEmpty());
@@ -411,8 +406,7 @@
         verifyClatdStart(null /* inOrder */);
 
         // Stacked interface up notification arrives.
-        nat.interfaceLinkStateChanged(STACKED_IFACE, true);
-        mLooper.dispatchNext();
+        nat.handleInterfaceLinkStateChanged(STACKED_IFACE, true);
 
         verify(mNetd).interfaceGetCfg(eq(STACKED_IFACE));
         verify(mConnectivity, times(1)).handleUpdateLinkProperties(eq(mNai), c.capture());
@@ -421,8 +415,7 @@
         assertRunning(nat);
 
         // Stacked interface removed notification arrives (clatd crashed, ...).
-        nat.interfaceRemoved(STACKED_IFACE);
-        mLooper.dispatchNext();
+        nat.handleInterfaceRemoved(STACKED_IFACE);
 
         verifyClatdStop(null /* inOrder */);
         verify(mConnectivity, times(2)).handleUpdateLinkProperties(eq(mNai), c.capture());
@@ -457,12 +450,10 @@
         assertIdle(nat);
 
         // In-flight interface up notification arrives: no-op
-        nat.interfaceLinkStateChanged(STACKED_IFACE, true);
-        mLooper.dispatchNext();
+        nat.handleInterfaceLinkStateChanged(STACKED_IFACE, true);
 
         // Interface removed notification arrives after stopClatd() takes effect: no-op.
-        nat.interfaceRemoved(STACKED_IFACE);
-        mLooper.dispatchNext();
+        nat.handleInterfaceRemoved(STACKED_IFACE);
 
         assertIdle(nat);
 
diff --git a/tests/unit/java/com/android/server/connectivity/NetworkRankerTest.kt b/tests/unit/java/com/android/server/connectivity/NetworkRankerTest.kt
index 1e3f389..87f7369 100644
--- a/tests/unit/java/com/android/server/connectivity/NetworkRankerTest.kt
+++ b/tests/unit/java/com/android/server/connectivity/NetworkRankerTest.kt
@@ -18,9 +18,12 @@
 
 import android.net.NetworkCapabilities
 import android.net.NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL as NET_CAP_PORTAL
+import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET as NET_CAP_INTERNET
+import android.net.NetworkCapabilities.NET_CAPABILITY_PRIORITIZE_BANDWIDTH as NET_CAP_PRIO_BW
 import android.net.NetworkCapabilities.TRANSPORT_CELLULAR
 import android.net.NetworkCapabilities.TRANSPORT_WIFI
 import android.net.NetworkScore.KEEP_CONNECTED_NONE
+import android.net.NetworkScore.POLICY_TRANSPORT_PRIMARY
 import android.net.NetworkScore.POLICY_EXITING as EXITING
 import android.net.NetworkScore.POLICY_TRANSPORT_PRIMARY as PRIMARY
 import android.net.NetworkScore.POLICY_YIELD_TO_BAD_WIFI as YIELD_TO_BAD_WIFI
@@ -50,8 +53,8 @@
 class NetworkRankerTest(private val activelyPreferBadWifi: Boolean) {
     private val mRanker = NetworkRanker(NetworkRanker.Configuration(activelyPreferBadWifi))
 
-    private class TestScore(private val sc: FullScore, private val nc: NetworkCapabilities)
-            : NetworkRanker.Scoreable {
+    private class TestScore(private val sc: FullScore, private val nc: NetworkCapabilities) :
+            NetworkRanker.Scoreable {
         override fun getScore() = sc
         override fun getCapsNoCopy(): NetworkCapabilities = nc
     }
@@ -196,4 +199,41 @@
         val badExitingWifi = TestScore(score(EVER_EVALUATED, EVER_VALIDATED, EXITING), CAPS_WIFI)
         assertEquals(cell, rank(cell, badExitingWifi))
     }
+
+    @Test
+    fun testValidatedPolicyStrongerThanSlice() {
+        val unvalidatedNonslice = TestScore(score(EVER_EVALUATED),
+                caps(TRANSPORT_CELLULAR, NET_CAP_INTERNET))
+        val slice = TestScore(score(EVER_EVALUATED, IS_VALIDATED),
+                caps(TRANSPORT_CELLULAR, NET_CAP_INTERNET, NET_CAP_PRIO_BW))
+        assertEquals(slice, rank(slice, unvalidatedNonslice))
+    }
+
+    @Test
+    fun testPrimaryPolicyStrongerThanSlice() {
+        val nonslice = TestScore(score(EVER_EVALUATED),
+                caps(TRANSPORT_CELLULAR, NET_CAP_INTERNET))
+        val primarySlice = TestScore(score(EVER_EVALUATED, POLICY_TRANSPORT_PRIMARY),
+                caps(TRANSPORT_CELLULAR, NET_CAP_INTERNET, NET_CAP_PRIO_BW))
+        assertEquals(primarySlice, rank(nonslice, primarySlice))
+    }
+
+    @Test
+    fun testPreferNonSlices() {
+        // Slices lose to non-slices for general ranking
+        val nonslice = TestScore(score(EVER_EVALUATED, IS_VALIDATED),
+                caps(TRANSPORT_CELLULAR, NET_CAP_INTERNET))
+        val slice = TestScore(score(EVER_EVALUATED, IS_VALIDATED),
+                caps(TRANSPORT_CELLULAR, NET_CAP_INTERNET, NET_CAP_PRIO_BW))
+        assertEquals(nonslice, rank(slice, nonslice))
+    }
+
+    @Test
+    fun testSlicePolicyStrongerThanTransport() {
+        val nonSliceCell = TestScore(score(EVER_EVALUATED, IS_VALIDATED),
+                caps(TRANSPORT_CELLULAR, NET_CAP_INTERNET))
+        val sliceWifi = TestScore(score(EVER_EVALUATED, IS_VALIDATED),
+                caps(TRANSPORT_WIFI, NET_CAP_INTERNET, NET_CAP_PRIO_BW))
+        assertEquals(nonSliceCell, rank(nonSliceCell, sliceWifi))
+    }
 }
diff --git a/tests/unit/java/com/android/server/connectivity/RoutingCoordinatorServiceTest.kt b/tests/unit/java/com/android/server/connectivity/RoutingCoordinatorServiceTest.kt
new file mode 100644
index 0000000..12758c6
--- /dev/null
+++ b/tests/unit/java/com/android/server/connectivity/RoutingCoordinatorServiceTest.kt
@@ -0,0 +1,64 @@
+/*
+ * 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.connectivity
+
+import android.net.INetd
+import android.os.Build
+import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo
+import com.android.testutils.DevSdkIgnoreRunner
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentMatchers.any
+import org.mockito.Mockito.inOrder
+import org.mockito.Mockito.mock
+import kotlin.test.assertFailsWith
+
+@RunWith(DevSdkIgnoreRunner::class)
+@IgnoreUpTo(Build.VERSION_CODES.TIRAMISU)
+class RoutingCoordinatorServiceTest {
+    val mNetd = mock(INetd::class.java)
+    val mService = RoutingCoordinatorService(mNetd)
+
+    @Test
+    fun testInterfaceForward() {
+        val inOrder = inOrder(mNetd)
+
+        mService.addInterfaceForward("from1", "to1")
+        inOrder.verify(mNetd).ipfwdEnableForwarding(any())
+        inOrder.verify(mNetd).tetherAddForward("from1", "to1")
+        inOrder.verify(mNetd).ipfwdAddInterfaceForward("from1", "to1")
+
+        mService.addInterfaceForward("from2", "to1")
+        inOrder.verify(mNetd).tetherAddForward("from2", "to1")
+        inOrder.verify(mNetd).ipfwdAddInterfaceForward("from2", "to1")
+
+        assertFailsWith<IllegalStateException> {
+            // Can't add the same pair again
+            mService.addInterfaceForward("from2", "to1")
+        }
+
+        mService.removeInterfaceForward("from1", "to1")
+        inOrder.verify(mNetd).ipfwdRemoveInterfaceForward("from1", "to1")
+        inOrder.verify(mNetd).tetherRemoveForward("from1", "to1")
+
+        mService.removeInterfaceForward("from2", "to1")
+        inOrder.verify(mNetd).ipfwdRemoveInterfaceForward("from2", "to1")
+        inOrder.verify(mNetd).tetherRemoveForward("from2", "to1")
+
+        inOrder.verify(mNetd).ipfwdDisableForwarding(any())
+    }
+}
diff --git a/tests/unit/java/com/android/server/connectivity/VpnTest.java b/tests/unit/java/com/android/server/connectivity/VpnTest.java
index 56346ad..48cfe77 100644
--- a/tests/unit/java/com/android/server/connectivity/VpnTest.java
+++ b/tests/unit/java/com/android/server/connectivity/VpnTest.java
@@ -579,6 +579,18 @@
     }
 
     @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;
@@ -724,6 +736,37 @@
     }
 
     @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[] {
@@ -788,6 +831,101 @@
     }
 
     @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;
@@ -1002,12 +1140,12 @@
 
         // 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(makeVpnUidRange(PRIMARY_USER.id, newExcludedUids),
+        assertEquals(makeVpnUidRangeSet(PRIMARY_USER.id, newExcludedUids),
                 vpn.mNetworkCapabilities.getUids());
         ArgumentCaptor<NetworkCapabilities> ncCaptor =
                 ArgumentCaptor.forClass(NetworkCapabilities.class);
         verify(mMockNetworkAgent).doSendNetworkCapabilities(ncCaptor.capture());
-        assertEquals(makeVpnUidRange(PRIMARY_USER.id, newExcludedUids),
+        assertEquals(makeVpnUidRangeSet(PRIMARY_USER.id, newExcludedUids),
                 ncCaptor.getValue().getUids());
 
         reset(mMockNetworkAgent);
@@ -1019,26 +1157,28 @@
 
         // 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(makeVpnUidRange(PRIMARY_USER.id, newExcludedUids),
+        assertEquals(makeVpnUidRangeSet(PRIMARY_USER.id, newExcludedUids),
                 vpn.mNetworkCapabilities.getUids());
         verify(mMockNetworkAgent).doSendNetworkCapabilities(ncCaptor.capture());
-        assertEquals(makeVpnUidRange(PRIMARY_USER.id, newExcludedUids),
+        assertEquals(makeVpnUidRangeSet(PRIMARY_USER.id, newExcludedUids),
                 ncCaptor.getValue().getUids());
     }
 
-    private Set<Range<Integer>> makeVpnUidRange(int userId, List<Integer> excludedList) {
+    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 uid : excludedList) {
-            final int applicationUid = UserHandle.getUid(userId, uid);
-            list.add(applicationUid);
-            list.add(Process.toSdkSandboxUid(applicationUid)); // Add Sdk Sandbox UID
+        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 Set<Range<Integer>> ranges = new ArraySet<>();
+        final List<Range<Integer>> ranges = new ArrayList<>();
 
         // Iterate the list to create the ranges between each uid.
         int start = minUid;
@@ -1059,6 +1199,10 @@
         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();
@@ -1839,6 +1983,22 @@
         // a subsequent CL.
     }
 
+    @Test
+    public void testStartLegacyVpnIpv6() throws Exception {
+        setMockedUsers(PRIMARY_USER);
+        final Vpn vpn = createVpn(PRIMARY_USER.id);
+        final LinkProperties lp = new LinkProperties();
+        lp.setInterfaceName(EGRESS_IFACE);
+        lp.addLinkAddress(new LinkAddress("2001:db8::1/64"));
+        final RouteInfo defaultRoute = new RouteInfo(
+                new IpPrefix(Inet6Address.ANY, 0), null, EGRESS_IFACE);
+        lp.addRoute(defaultRoute);
+
+        // IllegalStateException thrown since legacy VPN only supports IPv4.
+        assertThrows(IllegalStateException.class,
+                () -> vpn.startLegacyVpn(mVpnProfile, EGRESS_NETWORK, lp));
+    }
+
     private Vpn startLegacyVpn(final Vpn vpn, final VpnProfile vpnProfile) throws Exception {
         setMockedUsers(PRIMARY_USER);
 
@@ -2988,8 +3148,15 @@
         profile.mppe = useMppe;
 
         doReturn(new Network[] { new Network(101) }).when(mConnectivityManager).getAllNetworks();
-        doReturn(new Network(102)).when(mConnectivityManager).registerNetworkAgent(any(), any(),
-                any(), any(), any(), any(), anyInt());
+        doReturn(new Network(102)).when(mConnectivityManager).registerNetworkAgent(
+                any(), // INetworkAgent
+                any(), // NetworkInfo
+                any(), // LinkProperties
+                any(), // NetworkCapabilities
+                any(), // LocalNetworkConfig
+                any(), // NetworkScore
+                any(), // NetworkAgentConfig
+                anyInt()); // provider ID
 
         final Vpn vpn = startLegacyVpn(createVpn(PRIMARY_USER.id), profile);
         final TestDeps deps = (TestDeps) vpn.mDeps;
@@ -3011,8 +3178,15 @@
                 assertEquals("nomppe", mtpdArgs[argsPrefix.length]);
             }
 
-            verify(mConnectivityManager, timeout(10_000)).registerNetworkAgent(any(), any(),
-                    any(), any(), any(), any(), anyInt());
+            verify(mConnectivityManager, timeout(10_000)).registerNetworkAgent(
+                    any(), // INetworkAgent
+                    any(), // NetworkInfo
+                    any(), // LinkProperties
+                    any(), // NetworkCapabilities
+                    any(), // LocalNetworkConfig
+                    any(), // NetworkScore
+                    any(), // NetworkAgentConfig
+                    anyInt()); // provider ID
         }, () -> { // Cleanup
                 vpn.mVpnRunner.exitVpnRunner();
                 deps.getStateFile().delete(); // set to delete on exit, but this deletes it earlier
@@ -3037,7 +3211,7 @@
             .thenReturn(new Network[] { new Network(101) });
 
         when(mConnectivityManager.registerNetworkAgent(any(), any(), any(), any(),
-                any(), any(), anyInt())).thenAnswer(invocation -> {
+                any(), any(), any(), anyInt())).thenAnswer(invocation -> {
                     // The runner has registered an agent and is now ready.
                     legacyRunnerReady.open();
                     return new Network(102);
@@ -3063,7 +3237,7 @@
             ArgumentCaptor<NetworkCapabilities> ncCaptor =
                     ArgumentCaptor.forClass(NetworkCapabilities.class);
             verify(mConnectivityManager, timeout(10_000)).registerNetworkAgent(any(), any(),
-                    lpCaptor.capture(), ncCaptor.capture(), any(), any(), anyInt());
+                    lpCaptor.capture(), ncCaptor.capture(), any(), any(), any(), anyInt());
 
             // In this test the expected address is always v4 so /32.
             // Note that the interface needs to be specified because RouteInfo objects stored in
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 8eace1c..a86f923 100644
--- a/tests/unit/java/com/android/server/connectivity/mdns/MdnsAdvertiserTest.kt
+++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsAdvertiserTest.kt
@@ -153,10 +153,10 @@
         thread.start()
         doReturn(TEST_HOSTNAME).`when`(mockDeps).generateHostname()
         doReturn(mockInterfaceAdvertiser1).`when`(mockDeps).makeAdvertiser(eq(mockSocket1),
-                any(), any(), any(), any(), eq(TEST_HOSTNAME), any()
+                any(), any(), any(), any(), eq(TEST_HOSTNAME), any(), any()
         )
         doReturn(mockInterfaceAdvertiser2).`when`(mockDeps).makeAdvertiser(eq(mockSocket2),
-                any(), any(), any(), any(), eq(TEST_HOSTNAME), any()
+                any(), any(), any(), any(), eq(TEST_HOSTNAME), any(), any()
         )
         doReturn(true).`when`(mockInterfaceAdvertiser1).isProbing(anyInt())
         doReturn(true).`when`(mockInterfaceAdvertiser2).isProbing(anyInt())
@@ -202,6 +202,7 @@
             any(),
             intAdvCbCaptor.capture(),
             eq(TEST_HOSTNAME),
+            any(),
             any()
         )
 
@@ -259,10 +260,10 @@
         val intAdvCbCaptor1 = ArgumentCaptor.forClass(MdnsInterfaceAdvertiser.Callback::class.java)
         val intAdvCbCaptor2 = ArgumentCaptor.forClass(MdnsInterfaceAdvertiser.Callback::class.java)
         verify(mockDeps).makeAdvertiser(eq(mockSocket1), eq(listOf(TEST_LINKADDR)),
-                eq(thread.looper), any(), intAdvCbCaptor1.capture(), eq(TEST_HOSTNAME), any()
+                eq(thread.looper), any(), intAdvCbCaptor1.capture(), eq(TEST_HOSTNAME), any(), any()
         )
         verify(mockDeps).makeAdvertiser(eq(mockSocket2), eq(listOf(TEST_LINKADDR)),
-                eq(thread.looper), any(), intAdvCbCaptor2.capture(), eq(TEST_HOSTNAME), any()
+                eq(thread.looper), any(), intAdvCbCaptor2.capture(), eq(TEST_HOSTNAME), any(), any()
         )
         verify(mockInterfaceAdvertiser1).addService(
                 anyInt(), eq(ALL_NETWORKS_SERVICE), eq(TEST_SUBTYPE))
@@ -367,7 +368,7 @@
 
         val intAdvCbCaptor = ArgumentCaptor.forClass(MdnsInterfaceAdvertiser.Callback::class.java)
         verify(mockDeps).makeAdvertiser(eq(mockSocket1), eq(listOf(TEST_LINKADDR)),
-                eq(thread.looper), any(), intAdvCbCaptor.capture(), eq(TEST_HOSTNAME), any()
+                eq(thread.looper), any(), intAdvCbCaptor.capture(), eq(TEST_HOSTNAME), any(), any()
         )
         verify(mockInterfaceAdvertiser1).addService(eq(SERVICE_ID_1),
                 argThat { it.matches(SERVICE_1) }, eq(null))
diff --git a/tests/unit/java/com/android/server/connectivity/mdns/MdnsAnnouncerTest.kt b/tests/unit/java/com/android/server/connectivity/mdns/MdnsAnnouncerTest.kt
index c39ee1e..2797462 100644
--- a/tests/unit/java/com/android/server/connectivity/mdns/MdnsAnnouncerTest.kt
+++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsAnnouncerTest.kt
@@ -82,7 +82,8 @@
 
     @Test
     fun testAnnounce() {
-        val replySender = MdnsReplySender( thread.looper, socket, buffer, sharedLog)
+        val replySender = MdnsReplySender(
+                thread.looper, socket, buffer, sharedLog, true /* enableDebugLog */)
         @Suppress("UNCHECKED_CAST")
         val cb = mock(MdnsPacketRepeater.PacketRepeaterCallback::class.java)
                 as MdnsPacketRepeater.PacketRepeaterCallback<BaseAnnouncementInfo>
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 e869b91..331a5b6 100644
--- a/tests/unit/java/com/android/server/connectivity/mdns/MdnsDiscoveryManagerTests.java
+++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsDiscoveryManagerTests.java
@@ -106,7 +106,7 @@
         doReturn(thread.getLooper()).when(socketClient).getLooper();
         doReturn(true).when(socketClient).supportsRequestingSpecificNetworks();
         discoveryManager = new MdnsDiscoveryManager(executorProvider, socketClient,
-                sharedLog) {
+                sharedLog, MdnsFeatureFlags.newBuilder().build()) {
                     @Override
                     MdnsServiceTypeClient createServiceTypeClient(@NonNull String serviceType,
                             @NonNull SocketKey socketKey) {
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 c19747e..db41a6a 100644
--- a/tests/unit/java/com/android/server/connectivity/mdns/MdnsInterfaceAdvertiserTest.kt
+++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsInterfaceAdvertiserTest.kt
@@ -77,6 +77,7 @@
     private val announcer = mock(MdnsAnnouncer::class.java)
     private val prober = mock(MdnsProber::class.java)
     private val sharedlog = SharedLog("MdnsInterfaceAdvertiserTest")
+    private val flags = MdnsFeatureFlags.newBuilder().build()
     @Suppress("UNCHECKED_CAST")
     private val probeCbCaptor = ArgumentCaptor.forClass(PacketRepeaterCallback::class.java)
             as ArgumentCaptor<PacketRepeaterCallback<ProbingInfo>>
@@ -99,15 +100,14 @@
             cb,
             deps,
             TEST_HOSTNAME,
-            sharedlog
+            sharedlog,
+            flags
         )
     }
 
     @Before
     fun setUp() {
-        doReturn(repository).`when`(deps).makeRecordRepository(any(),
-            eq(TEST_HOSTNAME)
-        )
+        doReturn(repository).`when`(deps).makeRecordRepository(any(), eq(TEST_HOSTNAME), any())
         doReturn(replySender).`when`(deps).makeReplySender(anyString(), any(), any(), any(), any())
         doReturn(announcer).`when`(deps).makeMdnsAnnouncer(anyString(), any(), any(), any(), any())
         doReturn(prober).`when`(deps).makeMdnsProber(anyString(), any(), any(), any(), any())
@@ -190,8 +190,8 @@
     fun testReplyToQuery() {
         addServiceAndFinishProbing(TEST_SERVICE_ID_1, TEST_SERVICE_1)
 
-        val mockReply = mock(MdnsRecordRepository.ReplyInfo::class.java)
-        doReturn(mockReply).`when`(repository).getReply(any(), any())
+        val testReply = MdnsReplyInfo(emptyList(), emptyList(), 0, InetSocketAddress(0))
+        doReturn(testReply).`when`(repository).getReply(any(), any())
 
         // Query obtained with:
         // scapy.raw(scapy.DNS(
@@ -216,7 +216,7 @@
             assertContentEquals(arrayOf("_testservice", "_tcp", "local"), it.questions[0].name)
         }
 
-        verify(replySender).queueReply(mockReply)
+        verify(replySender).queueReply(testReply)
     }
 
     @Test
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 3701b0c..8917ed3 100644
--- a/tests/unit/java/com/android/server/connectivity/mdns/MdnsMultinetworkSocketClientTest.java
+++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsMultinetworkSocketClientTest.java
@@ -42,6 +42,7 @@
 import com.android.testutils.DevSdkIgnoreRunner;
 import com.android.testutils.HandlerUtils;
 
+import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -69,6 +70,7 @@
     @Mock private SocketCreationCallback mSocketCreationCallback;
     @Mock private SharedLog mSharedLog;
     private MdnsMultinetworkSocketClient mSocketClient;
+    private HandlerThread mHandlerThread;
     private Handler mHandler;
     private SocketKey mSocketKey;
 
@@ -76,14 +78,23 @@
     public void setUp() throws SocketException {
         MockitoAnnotations.initMocks(this);
 
-        final HandlerThread thread = new HandlerThread("MdnsMultinetworkSocketClientTest");
-        thread.start();
-        mHandler = new Handler(thread.getLooper());
+        mHandlerThread = new HandlerThread("MdnsMultinetworkSocketClientTest");
+        mHandlerThread.start();
+        mHandler = new Handler(mHandlerThread.getLooper());
         mSocketKey = new SocketKey(1000 /* interfaceIndex */);
-        mSocketClient = new MdnsMultinetworkSocketClient(thread.getLooper(), mProvider, mSharedLog);
+        mSocketClient = new MdnsMultinetworkSocketClient(
+                mHandlerThread.getLooper(), mProvider, mSharedLog);
         mHandler.post(() -> mSocketClient.setCallback(mCallback));
     }
 
+    @After
+    public void tearDown() throws Exception {
+        if (mHandlerThread != null) {
+            mHandlerThread.quitSafely();
+            mHandlerThread.join();
+        }
+    }
+
     private SocketCallback expectSocketCallback() {
         return expectSocketCallback(mListener, mNetwork);
     }
diff --git a/tests/unit/java/com/android/server/connectivity/mdns/MdnsPacketTest.kt b/tests/unit/java/com/android/server/connectivity/mdns/MdnsPacketTest.kt
index b667e5f..28ea4b6 100644
--- a/tests/unit/java/com/android/server/connectivity/mdns/MdnsPacketTest.kt
+++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsPacketTest.kt
@@ -32,7 +32,7 @@
         // Probe packet with 1 question for Android.local, and 4 additionalRecords with 4 addresses
         // for Android.local (similar to legacy mdnsresponder probes, although it used to put 4
         // identical questions(!!) for Android.local when there were 4 addresses).
-        val packetHex = "00000000000100000004000007416e64726f6964056c6f63616c0000ff0001c00c000100" +
+        val packetHex = "007b0000000100000004000007416e64726f6964056c6f63616c0000ff0001c00c000100" +
                 "01000000780004c000027bc00c001c000100000078001020010db8000000000000000000000123c0" +
                 "0c001c000100000078001020010db8000000000000000000000456c00c001c000100000078001020" +
                 "010db8000000000000000000000789"
@@ -41,6 +41,7 @@
         val reader = MdnsPacketReader(bytes, bytes.size)
         val packet = MdnsPacket.parse(reader)
 
+        assertEquals(123, packet.transactionId)
         assertEquals(1, packet.questions.size)
         assertEquals(0, packet.answers.size)
         assertEquals(4, packet.authorityRecords.size)
diff --git a/tests/unit/java/com/android/server/connectivity/mdns/MdnsProberTest.kt b/tests/unit/java/com/android/server/connectivity/mdns/MdnsProberTest.kt
index f284819..5b7c0ba 100644
--- a/tests/unit/java/com/android/server/connectivity/mdns/MdnsProberTest.kt
+++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsProberTest.kt
@@ -119,7 +119,8 @@
 
     @Test
     fun testProbe() {
-        val replySender = MdnsReplySender(thread.looper, socket, buffer, sharedLog)
+        val replySender = MdnsReplySender(
+                thread.looper, socket, buffer, sharedLog, true /* enableDebugLog */)
         val prober = TestProber(thread.looper, replySender, cb, sharedLog)
         val probeInfo = TestProbeInfo(
                 listOf(makeServiceRecord(TEST_SERVICE_NAME_1, 37890)))
@@ -143,7 +144,8 @@
 
     @Test
     fun testProbeMultipleRecords() {
-        val replySender = MdnsReplySender(thread.looper, socket, buffer, sharedLog)
+        val replySender = MdnsReplySender(
+                thread.looper, socket, buffer, sharedLog, true /* enableDebugLog */)
         val prober = TestProber(thread.looper, replySender, cb, sharedLog)
         val probeInfo = TestProbeInfo(listOf(
                 makeServiceRecord(TEST_SERVICE_NAME_1, 37890),
@@ -181,7 +183,8 @@
 
     @Test
     fun testStopProbing() {
-        val replySender = MdnsReplySender(thread.looper, socket, buffer, sharedLog)
+        val replySender = MdnsReplySender(
+                thread.looper, socket, buffer, sharedLog, true /* enableDebugLog */)
         val prober = TestProber(thread.looper, replySender, cb, sharedLog)
         val probeInfo = TestProbeInfo(
                 listOf(makeServiceRecord(TEST_SERVICE_NAME_1, 37890)),
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 88fb66a..f26f7e1 100644
--- a/tests/unit/java/com/android/server/connectivity/mdns/MdnsRecordRepositoryTest.kt
+++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsRecordRepositoryTest.kt
@@ -78,6 +78,7 @@
         override fun getInterfaceInetAddresses(iface: NetworkInterface) =
                 Collections.enumeration(TEST_ADDRESSES.map { it.address })
     }
+    private val flags = MdnsFeatureFlags.newBuilder().build()
 
     @Before
     fun setUp() {
@@ -92,7 +93,7 @@
 
     @Test
     fun testAddServiceAndProbe() {
-        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME)
+        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, flags)
         assertEquals(0, repository.servicesCount)
         assertEquals(-1, repository.addService(TEST_SERVICE_ID_1, TEST_SERVICE_1,
                 null /* subtype */))
@@ -105,6 +106,7 @@
         assertEquals(TEST_SERVICE_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)
@@ -126,7 +128,7 @@
 
     @Test
     fun testAddAndConflicts() {
-        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME)
+        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, flags)
         repository.addService(TEST_SERVICE_ID_1, TEST_SERVICE_1, null /* subtype */)
         assertFailsWith(NameConflictException::class) {
             repository.addService(TEST_SERVICE_ID_2, TEST_SERVICE_1, null /* subtype */)
@@ -138,7 +140,7 @@
 
     @Test
     fun testInvalidReuseOfServiceId() {
-        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME)
+        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, flags)
         repository.addService(TEST_SERVICE_ID_1, TEST_SERVICE_1, null /* subtype */)
         assertFailsWith(IllegalArgumentException::class) {
             repository.addService(TEST_SERVICE_ID_1, TEST_SERVICE_2, null /* subtype */)
@@ -147,7 +149,7 @@
 
     @Test
     fun testHasActiveService() {
-        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME)
+        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, flags)
         assertFalse(repository.hasActiveService(TEST_SERVICE_ID_1))
 
         repository.addService(TEST_SERVICE_ID_1, TEST_SERVICE_1, null /* subtype */)
@@ -164,7 +166,7 @@
 
     @Test
     fun testExitAnnouncements() {
-        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME)
+        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, flags)
         repository.initWithService(TEST_SERVICE_ID_1, TEST_SERVICE_1)
         repository.onAdvertisementSent(TEST_SERVICE_ID_1, 2 /* sentPacketCount */)
 
@@ -173,6 +175,7 @@
         assertEquals(1, repository.servicesCount)
         val packet = exitAnnouncement.getPacket(0)
 
+        assertEquals(0, packet.transactionId)
         assertEquals(0x8400 /* response, authoritative */, packet.flags)
         assertEquals(0, packet.questions.size)
         assertEquals(0, packet.authorityRecords.size)
@@ -193,7 +196,7 @@
 
     @Test
     fun testExitAnnouncements_WithSubtype() {
-        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME)
+        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, flags)
         repository.initWithService(TEST_SERVICE_ID_1, TEST_SERVICE_1, TEST_SUBTYPE)
         repository.onAdvertisementSent(TEST_SERVICE_ID_1, 2 /* sentPacketCount */)
 
@@ -202,6 +205,7 @@
         assertEquals(1, repository.servicesCount)
         val packet = exitAnnouncement.getPacket(0)
 
+        assertEquals(0, packet.transactionId)
         assertEquals(0x8400 /* response, authoritative */, packet.flags)
         assertEquals(0, packet.questions.size)
         assertEquals(0, packet.authorityRecords.size)
@@ -228,7 +232,7 @@
 
     @Test
     fun testExitingServiceReAdded() {
-        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME)
+        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, flags)
         repository.initWithService(TEST_SERVICE_ID_1, TEST_SERVICE_1)
         repository.onAdvertisementSent(TEST_SERVICE_ID_1, 2 /* sentPacketCount */)
         repository.exitService(TEST_SERVICE_ID_1)
@@ -243,12 +247,13 @@
 
     @Test
     fun testOnProbingSucceeded() {
-        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME)
+        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, flags)
         val announcementInfo = repository.initWithService(TEST_SERVICE_ID_1, TEST_SERVICE_1,
                 TEST_SUBTYPE)
         repository.onAdvertisementSent(TEST_SERVICE_ID_1, 2 /* sentPacketCount */)
         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)
@@ -367,11 +372,12 @@
 
     @Test
     fun testGetOffloadPacket() {
-        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME)
+        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, flags)
         repository.initWithService(TEST_SERVICE_ID_1, TEST_SERVICE_1)
         val serviceName = arrayOf("MyTestService", "_testservice", "_tcp", "local")
         val serviceType = arrayOf("_testservice", "_tcp", "local")
         val offloadPacket = repository.getOffloadPacket(TEST_SERVICE_ID_1)
+        assertEquals(0, offloadPacket.transactionId)
         assertEquals(0x8400, offloadPacket.flags)
         assertEquals(0, offloadPacket.questions.size)
         assertEquals(0, offloadPacket.additionalRecords.size)
@@ -428,7 +434,7 @@
 
     @Test
     fun testGetReplyCaseInsensitive() {
-        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME)
+        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, flags)
         repository.initWithService(TEST_SERVICE_ID_1, TEST_SERVICE_1)
         val questionsCaseInSensitive =
             listOf(MdnsPointerRecord(arrayOf("_TESTSERVICE", "_TCP", "local"),
@@ -458,7 +464,7 @@
     }
 
     private fun doGetReplyTest(subtype: String?) {
-        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME)
+        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, flags)
         repository.initWithService(TEST_SERVICE_ID_1, TEST_SERVICE_1, subtype)
         val queriedName = if (subtype == null) arrayOf("_testservice", "_tcp", "local")
         else arrayOf(subtype, "_sub", "_testservice", "_tcp", "local")
@@ -546,7 +552,7 @@
 
     @Test
     fun testGetConflictingServices() {
-        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME)
+        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, flags)
         repository.addService(TEST_SERVICE_ID_1, TEST_SERVICE_1, null /* subtype */)
         repository.addService(TEST_SERVICE_ID_2, TEST_SERVICE_2, null /* subtype */)
 
@@ -574,7 +580,7 @@
 
     @Test
     fun testGetConflictingServicesCaseInsensitive() {
-        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME)
+        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, flags)
         repository.addService(TEST_SERVICE_ID_1, TEST_SERVICE_1, null /* subtype */)
         repository.addService(TEST_SERVICE_ID_2, TEST_SERVICE_2, null /* subtype */)
 
@@ -602,7 +608,7 @@
 
     @Test
     fun testGetConflictingServices_IdenticalService() {
-        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME)
+        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, flags)
         repository.addService(TEST_SERVICE_ID_1, TEST_SERVICE_1, null /* subtype */)
         repository.addService(TEST_SERVICE_ID_2, TEST_SERVICE_2, null /* subtype */)
 
@@ -631,7 +637,7 @@
 
     @Test
     fun testGetConflictingServicesCaseInsensitive_IdenticalService() {
-        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME)
+        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, flags)
         repository.addService(TEST_SERVICE_ID_1, TEST_SERVICE_1, null /* subtype */)
         repository.addService(TEST_SERVICE_ID_2, TEST_SERVICE_2, null /* subtype */)
 
@@ -660,7 +666,7 @@
 
     @Test
     fun testGetServiceRepliedRequestsCount() {
-        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME)
+        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, flags)
         repository.initWithService(TEST_SERVICE_ID_1, TEST_SERVICE_1)
         // Verify that there is no packet replied.
         assertEquals(MdnsConstants.NO_PACKET,
@@ -685,6 +691,68 @@
         assertEquals(MdnsConstants.NO_PACKET,
                 repository.getServiceRepliedRequestsCount(TEST_SERVICE_ID_2))
     }
+
+    @Test
+    fun testIncludeInetAddressRecordsInProbing() {
+        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME,
+                MdnsFeatureFlags.newBuilder().setIncludeInetAddressRecordsInProbing(true).build())
+        repository.updateAddresses(TEST_ADDRESSES)
+        assertEquals(0, repository.servicesCount)
+        assertEquals(-1, repository.addService(TEST_SERVICE_ID_1, TEST_SERVICE_1,
+                null /* subtype */))
+        assertEquals(1, repository.servicesCount)
+
+        val probingInfo = repository.setServiceProbing(TEST_SERVICE_ID_1)
+        assertNotNull(probingInfo)
+        assertTrue(repository.isProbing(TEST_SERVICE_ID_1))
+
+        assertEquals(TEST_SERVICE_ID_1, probingInfo.serviceId)
+        val packet = probingInfo.getPacket(0)
+
+        assertEquals(MdnsConstants.FLAGS_QUERY, packet.flags)
+        assertEquals(0, packet.answers.size)
+        assertEquals(0, packet.additionalRecords.size)
+
+        assertEquals(2, packet.questions.size)
+        val expectedName = arrayOf("MyTestService", "_testservice", "_tcp", "local")
+        assertContentEquals(listOf(
+            MdnsAnyRecord(expectedName, false /* unicast */),
+            MdnsAnyRecord(TEST_HOSTNAME, false /* unicast */),
+        ), packet.questions)
+
+        assertEquals(4, packet.authorityRecords.size)
+        assertContentEquals(listOf(
+            MdnsServiceRecord(
+                expectedName,
+                0L /* receiptTimeMillis */,
+                false /* cacheFlush */,
+                120_000L /* ttlMillis */,
+                0 /* servicePriority */,
+                0 /* serviceWeight */,
+                TEST_PORT,
+                TEST_HOSTNAME),
+            MdnsInetAddressRecord(
+                TEST_HOSTNAME,
+                0L /* receiptTimeMillis */,
+                false /* cacheFlush */,
+                120_000L /* ttlMillis */,
+                TEST_ADDRESSES[0].address),
+            MdnsInetAddressRecord(
+                TEST_HOSTNAME,
+                0L /* receiptTimeMillis */,
+                false /* cacheFlush */,
+                120_000L /* ttlMillis */,
+                TEST_ADDRESSES[1].address),
+            MdnsInetAddressRecord(
+                TEST_HOSTNAME,
+                0L /* receiptTimeMillis */,
+                false /* cacheFlush */,
+                120_000L /* ttlMillis */,
+                TEST_ADDRESSES[2].address)
+        ), packet.authorityRecords)
+
+        assertContentEquals(intArrayOf(TEST_SERVICE_ID_1), repository.clearServices())
+    }
 }
 
 private fun MdnsRecordRepository.initWithService(
diff --git a/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceCacheTest.kt b/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceCacheTest.kt
index b43bcf7..2b3b834 100644
--- a/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceCacheTest.kt
+++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceCacheTest.kt
@@ -19,6 +19,7 @@
 import android.os.Build
 import android.os.Handler
 import android.os.HandlerThread
+import com.android.server.connectivity.mdns.MdnsServiceCache.CacheKey
 import com.android.testutils.DevSdkIgnoreRule
 import com.android.testutils.DevSdkIgnoreRunner
 import java.util.concurrent.CompletableFuture
@@ -43,13 +44,12 @@
 @DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.S_V2)
 class MdnsServiceCacheTest {
     private val socketKey = SocketKey(null /* network */, INTERFACE_INDEX)
+    private val cacheKey1 = CacheKey(SERVICE_TYPE_1, socketKey)
+    private val cacheKey2 = CacheKey(SERVICE_TYPE_2, socketKey)
     private val thread = HandlerThread(MdnsServiceCacheTest::class.simpleName)
     private val handler by lazy {
         Handler(thread.looper)
     }
-    private val serviceCache by lazy {
-        MdnsServiceCache(thread.looper)
-    }
 
     @Before
     fun setUp() {
@@ -61,6 +61,11 @@
         thread.quitSafely()
     }
 
+    private fun makeFlags(isExpiredServicesRemovalEnabled: Boolean = false) =
+            MdnsFeatureFlags.Builder()
+                    .setIsExpiredServicesRemovalEnabled(isExpiredServicesRemovalEnabled)
+                    .build()
+
     private fun <T> runningOnHandlerAndReturn(functor: (() -> T)): T {
         val future = CompletableFuture<T>()
         handler.post {
@@ -70,46 +75,50 @@
     }
 
     private fun addOrUpdateService(
-            serviceType: String,
-            socketKey: SocketKey,
+            serviceCache: MdnsServiceCache,
+            cacheKey: CacheKey,
             service: MdnsResponse
-    ): Unit = runningOnHandlerAndReturn {
-        serviceCache.addOrUpdateService(serviceType, socketKey, service)
+    ): Unit = runningOnHandlerAndReturn { serviceCache.addOrUpdateService(cacheKey, service) }
+
+    private fun removeService(
+            serviceCache: MdnsServiceCache,
+            serviceName: String,
+            cacheKey: CacheKey
+    ): Unit = runningOnHandlerAndReturn { serviceCache.removeService(serviceName, cacheKey) }
+
+    private fun getService(
+            serviceCache: MdnsServiceCache,
+            serviceName: String,
+            cacheKey: CacheKey,
+    ): MdnsResponse? = runningOnHandlerAndReturn {
+        serviceCache.getCachedService(serviceName, cacheKey)
     }
 
-    private fun removeService(serviceName: String, serviceType: String, socketKey: SocketKey):
-            Unit = runningOnHandlerAndReturn {
-        serviceCache.removeService(serviceName, serviceType, socketKey) }
-
-    private fun getService(serviceName: String, serviceType: String, socketKey: SocketKey):
-            MdnsResponse? = runningOnHandlerAndReturn {
-        serviceCache.getCachedService(serviceName, serviceType, socketKey) }
-
-    private fun getServices(serviceType: String, socketKey: SocketKey): List<MdnsResponse> =
-        runningOnHandlerAndReturn { serviceCache.getCachedServices(serviceType, socketKey) }
+    private fun getServices(
+            serviceCache: MdnsServiceCache,
+            cacheKey: CacheKey,
+    ): List<MdnsResponse> = runningOnHandlerAndReturn { serviceCache.getCachedServices(cacheKey) }
 
     @Test
     fun testAddAndRemoveService() {
-        addOrUpdateService(
-                SERVICE_TYPE_1, socketKey, createResponse(SERVICE_NAME_1, SERVICE_TYPE_1))
-        var response = getService(SERVICE_NAME_1, SERVICE_TYPE_1, socketKey)
+        val serviceCache = MdnsServiceCache(thread.looper, makeFlags())
+        addOrUpdateService(serviceCache, cacheKey1, createResponse(SERVICE_NAME_1, SERVICE_TYPE_1))
+        var response = getService(serviceCache, SERVICE_NAME_1, cacheKey1)
         assertNotNull(response)
         assertEquals(SERVICE_NAME_1, response.serviceInstanceName)
-        removeService(SERVICE_NAME_1, SERVICE_TYPE_1, socketKey)
-        response = getService(SERVICE_NAME_1, SERVICE_TYPE_1, socketKey)
+        removeService(serviceCache, SERVICE_NAME_1, cacheKey1)
+        response = getService(serviceCache, SERVICE_NAME_1, cacheKey1)
         assertNull(response)
     }
 
     @Test
     fun testGetCachedServices_multipleServiceTypes() {
-        addOrUpdateService(
-                SERVICE_TYPE_1, socketKey, createResponse(SERVICE_NAME_1, SERVICE_TYPE_1))
-        addOrUpdateService(
-                SERVICE_TYPE_1, socketKey, createResponse(SERVICE_NAME_2, SERVICE_TYPE_1))
-        addOrUpdateService(
-                SERVICE_TYPE_2, socketKey, createResponse(SERVICE_NAME_2, SERVICE_TYPE_2))
+        val serviceCache = MdnsServiceCache(thread.looper, makeFlags())
+        addOrUpdateService(serviceCache, cacheKey1, createResponse(SERVICE_NAME_1, SERVICE_TYPE_1))
+        addOrUpdateService(serviceCache, cacheKey1, createResponse(SERVICE_NAME_2, SERVICE_TYPE_1))
+        addOrUpdateService(serviceCache, cacheKey2, createResponse(SERVICE_NAME_2, SERVICE_TYPE_2))
 
-        val responses1 = getServices(SERVICE_TYPE_1, socketKey)
+        val responses1 = getServices(serviceCache, cacheKey1)
         assertEquals(2, responses1.size)
         assertTrue(responses1.stream().anyMatch { response ->
             response.serviceInstanceName == SERVICE_NAME_1
@@ -117,19 +126,19 @@
         assertTrue(responses1.any { response ->
             response.serviceInstanceName == SERVICE_NAME_2
         })
-        val responses2 = getServices(SERVICE_TYPE_2, socketKey)
+        val responses2 = getServices(serviceCache, cacheKey2)
         assertEquals(1, responses2.size)
         assertTrue(responses2.any { response ->
             response.serviceInstanceName == SERVICE_NAME_2
         })
 
-        removeService(SERVICE_NAME_2, SERVICE_TYPE_1, socketKey)
-        val responses3 = getServices(SERVICE_TYPE_1, socketKey)
+        removeService(serviceCache, SERVICE_NAME_2, cacheKey1)
+        val responses3 = getServices(serviceCache, cacheKey1)
         assertEquals(1, responses3.size)
         assertTrue(responses3.any { response ->
             response.serviceInstanceName == SERVICE_NAME_1
         })
-        val responses4 = getServices(SERVICE_TYPE_2, socketKey)
+        val responses4 = getServices(serviceCache, cacheKey2)
         assertEquals(1, responses4.size)
         assertTrue(responses4.any { response ->
             response.serviceInstanceName == SERVICE_NAME_2
@@ -137,6 +146,6 @@
     }
 
     private fun createResponse(serviceInstanceName: String, serviceType: String) = MdnsResponse(
-        0 /* now */, "$serviceInstanceName.$serviceType".split(".").toTypedArray(),
+            0 /* now */, "$serviceInstanceName.$serviceType".split(".").toTypedArray(),
             socketKey.interfaceIndex, socketKey.network)
 }
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 fde5abd..ce154dd 100644
--- a/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceTypeClientTests.java
+++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceTypeClientTests.java
@@ -193,7 +193,8 @@
         thread = new HandlerThread("MdnsServiceTypeClientTests");
         thread.start();
         handler = new Handler(thread.getLooper());
-        serviceCache = new MdnsServiceCache(thread.getLooper());
+        serviceCache = new MdnsServiceCache(
+                thread.getLooper(), MdnsFeatureFlags.newBuilder().build());
 
         doAnswer(inv -> {
             latestDelayMs = 0;
diff --git a/tests/unit/java/com/android/server/connectivityservice/CSBasicMethodsTest.kt b/tests/unit/java/com/android/server/connectivityservice/CSBasicMethodsTest.kt
index 6f8ba6c..58f20a9 100644
--- a/tests/unit/java/com/android/server/connectivityservice/CSBasicMethodsTest.kt
+++ b/tests/unit/java/com/android/server/connectivityservice/CSBasicMethodsTest.kt
@@ -1,3 +1,19 @@
+/*
+ * 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.
+ */
+
 @file:Suppress("DEPRECATION") // This file tests a bunch of deprecated methods : don't warn about it
 
 package com.android.server
diff --git a/tests/unit/java/com/android/server/connectivityservice/CSDestroyedNetworkTests.kt b/tests/unit/java/com/android/server/connectivityservice/CSDestroyedNetworkTests.kt
new file mode 100644
index 0000000..572c7bb
--- /dev/null
+++ b/tests/unit/java/com/android/server/connectivityservice/CSDestroyedNetworkTests.kt
@@ -0,0 +1,64 @@
+/*
+ * 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.NetworkCapabilities
+import android.net.NetworkCapabilities.TRANSPORT_WIFI
+import android.net.NetworkRequest
+import android.os.Build
+import androidx.test.filters.SmallTest
+import com.android.testutils.DevSdkIgnoreRule
+import com.android.testutils.DevSdkIgnoreRunner
+import com.android.testutils.RecorderCallback.CallbackEntry.Lost
+import com.android.testutils.TestableNetworkCallback
+import org.junit.Test
+import org.junit.runner.RunWith
+
+private const val LONG_TIMEOUT_MS = 5_000
+
+@RunWith(DevSdkIgnoreRunner::class)
+@SmallTest
+@DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.R)
+class CSDestroyedNetworkTests : CSTest() {
+    @Test
+    fun testDestroyNetworkNotKeptWhenUnvalidated() {
+        val nc = NetworkCapabilities.Builder()
+                .addTransportType(TRANSPORT_WIFI)
+                .build()
+
+        val nr = NetworkRequest.Builder()
+                .clearCapabilities()
+                .addTransportType(TRANSPORT_WIFI)
+                .build()
+        val cbRequest = TestableNetworkCallback()
+        val cbCallback = TestableNetworkCallback()
+        cm.requestNetwork(nr, cbRequest)
+        cm.registerNetworkCallback(nr, cbCallback)
+
+        val firstAgent = Agent(nc = nc)
+        firstAgent.connect()
+        cbCallback.expectAvailableCallbacks(firstAgent.network, validated = false)
+
+        firstAgent.unregisterAfterReplacement(LONG_TIMEOUT_MS)
+
+        val secondAgent = Agent(nc = nc)
+        secondAgent.connect()
+        cbCallback.expectAvailableCallbacks(secondAgent.network, validated = false)
+
+        cbCallback.expect<Lost>(timeoutMs = 500) { it.network == firstAgent.network }
+    }
+}
diff --git a/tests/unit/java/com/android/server/connectivityservice/CSKeepConnectedTest.kt b/tests/unit/java/com/android/server/connectivityservice/CSKeepConnectedTest.kt
new file mode 100644
index 0000000..2126a09
--- /dev/null
+++ b/tests/unit/java/com/android/server/connectivityservice/CSKeepConnectedTest.kt
@@ -0,0 +1,78 @@
+/*
+ * 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.LocalNetworkConfig
+import android.net.NetworkCapabilities
+import android.net.NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK
+import android.net.NetworkCapabilities.TRANSPORT_WIFI
+import android.net.NetworkRequest
+import android.net.NetworkScore
+import android.net.NetworkScore.KEEP_CONNECTED_LOCAL_NETWORK
+import android.net.NetworkScore.KEEP_CONNECTED_FOR_TEST
+import android.os.Build
+import androidx.test.filters.SmallTest
+import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo
+import com.android.testutils.DevSdkIgnoreRunner
+import com.android.testutils.RecorderCallback.CallbackEntry.Lost
+import com.android.testutils.TestableNetworkCallback
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(DevSdkIgnoreRunner::class)
+@SmallTest
+@IgnoreUpTo(Build.VERSION_CODES.TIRAMISU)
+class CSKeepConnectedTest : CSTest() {
+    @Test
+    fun testKeepConnectedLocalAgent() {
+        deps.setBuildSdk(VERSION_V)
+        val nc = NetworkCapabilities.Builder()
+                .addTransportType(TRANSPORT_WIFI)
+                .addCapability(NET_CAPABILITY_LOCAL_NETWORK)
+                .build()
+        val keepConnectedAgent = Agent(nc = nc, score = FromS(NetworkScore.Builder()
+                .setKeepConnectedReason(KEEP_CONNECTED_LOCAL_NETWORK)
+                .build()),
+                lnc = LocalNetworkConfig.Builder().build())
+        val dontKeepConnectedAgent = Agent(nc = nc, lnc = LocalNetworkConfig.Builder().build())
+        doTestKeepConnected(keepConnectedAgent, dontKeepConnectedAgent)
+    }
+
+    @Test
+    fun testKeepConnectedForTest() {
+        val keepAgent = Agent(score = FromS(NetworkScore.Builder()
+                .setKeepConnectedReason(KEEP_CONNECTED_FOR_TEST)
+                .build()))
+        val dontKeepAgent = Agent()
+        doTestKeepConnected(keepAgent, dontKeepAgent)
+    }
+
+    fun doTestKeepConnected(keepAgent: CSAgentWrapper, dontKeepAgent: CSAgentWrapper) {
+        val cb = TestableNetworkCallback()
+        cm.registerNetworkCallback(NetworkRequest.Builder().clearCapabilities().build(), cb)
+
+        keepAgent.connect()
+        dontKeepAgent.connect()
+
+        cb.expectAvailableCallbacks(keepAgent.network, validated = false)
+        cb.expectAvailableCallbacks(dontKeepAgent.network, validated = false)
+
+        // After the nascent timer, the agent without keep connected gets lost.
+        cb.expect<Lost>(dontKeepAgent.network)
+        cb.assertNoCallback()
+    }
+}
diff --git a/tests/unit/java/com/android/server/connectivityservice/CSLocalAgentCreationTests.kt b/tests/unit/java/com/android/server/connectivityservice/CSLocalAgentCreationTests.kt
new file mode 100644
index 0000000..cfc3a3d
--- /dev/null
+++ b/tests/unit/java/com/android/server/connectivityservice/CSLocalAgentCreationTests.kt
@@ -0,0 +1,131 @@
+/*
+ * 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.content.pm.PackageManager.FEATURE_LEANBACK
+import android.net.INetd
+import android.net.LocalNetworkConfig
+import android.net.NativeNetworkConfig
+import android.net.NativeNetworkType
+import android.net.NetworkCapabilities
+import android.net.NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK
+import android.net.NetworkRequest
+import android.net.NetworkScore
+import android.net.NetworkScore.KEEP_CONNECTED_FOR_TEST
+import android.net.VpnManager
+import android.os.Build
+import androidx.test.filters.SmallTest
+import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo
+import com.android.testutils.DevSdkIgnoreRunner
+import com.android.testutils.RecorderCallback.CallbackEntry.Available
+import com.android.testutils.TestableNetworkCallback
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+import org.mockito.Mockito.doReturn
+import org.mockito.Mockito.inOrder
+import org.mockito.Mockito.never
+import org.mockito.Mockito.timeout
+import kotlin.test.assertFailsWith
+
+private const val TIMEOUT_MS = 2_000L
+private const val NO_CALLBACK_TIMEOUT_MS = 200L
+
+private fun keepConnectedScore() =
+        FromS(NetworkScore.Builder().setKeepConnectedReason(KEEP_CONNECTED_FOR_TEST).build())
+
+private fun defaultLnc() = LocalNetworkConfig.Builder().build()
+
+@RunWith(DevSdkIgnoreRunner::class)
+@SmallTest
+@IgnoreUpTo(Build.VERSION_CODES.R)
+class CSLocalAgentCreationTests(
+        private val sdkLevel: Int,
+        private val isTv: Boolean,
+        private val addLocalNetCapToRequest: Boolean
+) : CSTest() {
+    companion object {
+        @JvmStatic
+        @Parameterized.Parameters
+        fun arguments() = listOf(
+                arrayOf(VERSION_V, false /* isTv */, true /* addLocalNetCapToRequest */),
+                arrayOf(VERSION_V, false /* isTv */, false /* addLocalNetCapToRequest */),
+                arrayOf(VERSION_V, true /* isTv */, true /* addLocalNetCapToRequest */),
+                arrayOf(VERSION_V, true /* isTv */, false /* addLocalNetCapToRequest */),
+                arrayOf(VERSION_U, false /* isTv */, true /* addLocalNetCapToRequest */),
+                arrayOf(VERSION_U, false /* isTv */, false /* addLocalNetCapToRequest */),
+                arrayOf(VERSION_U, true /* isTv */, true /* addLocalNetCapToRequest */),
+                arrayOf(VERSION_U, true /* isTv */, false /* addLocalNetCapToRequest */),
+                arrayOf(VERSION_T, false /* isTv */, false /* addLocalNetCapToRequest */),
+                arrayOf(VERSION_T, true /* isTv */, false /* addLocalNetCapToRequest */),
+        )
+    }
+
+    private fun makeNativeNetworkConfigLocal(netId: Int, permission: Int) =
+            NativeNetworkConfig(netId, NativeNetworkType.PHYSICAL_LOCAL, permission,
+                    false /* secure */, VpnManager.TYPE_VPN_NONE, false /* excludeLocalRoutes */)
+
+    @Test
+    fun testLocalAgents() {
+        val netdInOrder = inOrder(netd)
+        deps.setBuildSdk(sdkLevel)
+        doReturn(isTv).`when`(packageManager).hasSystemFeature(FEATURE_LEANBACK)
+        val allNetworksCb = TestableNetworkCallback()
+        val request = NetworkRequest.Builder()
+        if (addLocalNetCapToRequest) {
+            request.addCapability(NET_CAPABILITY_LOCAL_NETWORK)
+        }
+        cm.registerNetworkCallback(request.build(), allNetworksCb)
+        val ncTemplate = NetworkCapabilities.Builder().run {
+            addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
+            addCapability(NET_CAPABILITY_LOCAL_NETWORK)
+        }.build()
+        val localAgent = if (sdkLevel >= VERSION_V || sdkLevel == VERSION_U && isTv) {
+            Agent(nc = ncTemplate, score = keepConnectedScore(), lnc = defaultLnc())
+        } else {
+            assertFailsWith<IllegalArgumentException> { Agent(nc = ncTemplate, lnc = defaultLnc()) }
+            netdInOrder.verify(netd, never()).networkCreate(any())
+            return
+        }
+        localAgent.connect()
+        netdInOrder.verify(netd).networkCreate(
+                makeNativeNetworkConfigLocal(localAgent.network.netId, INetd.PERMISSION_NONE))
+        if (addLocalNetCapToRequest) {
+            assertEquals(localAgent.network, allNetworksCb.expect<Available>().network)
+        } else {
+            allNetworksCb.assertNoCallback(NO_CALLBACK_TIMEOUT_MS)
+        }
+        cm.unregisterNetworkCallback(allNetworksCb)
+        localAgent.disconnect()
+        netdInOrder.verify(netd, timeout(TIMEOUT_MS)).networkDestroy(localAgent.network.netId)
+    }
+
+    @Test
+    fun testBadAgents() {
+        assertFailsWith<IllegalArgumentException> {
+            Agent(nc = NetworkCapabilities.Builder()
+                    .addCapability(NET_CAPABILITY_LOCAL_NETWORK)
+                    .build(),
+                    lnc = null)
+        }
+        assertFailsWith<IllegalArgumentException> {
+            Agent(nc = NetworkCapabilities.Builder().build(),
+                    lnc = LocalNetworkConfig.Builder().build())
+        }
+    }
+}
diff --git a/tests/unit/java/com/android/server/connectivityservice/CSLocalAgentTests.kt b/tests/unit/java/com/android/server/connectivityservice/CSLocalAgentTests.kt
new file mode 100644
index 0000000..3a76ad0
--- /dev/null
+++ b/tests/unit/java/com/android/server/connectivityservice/CSLocalAgentTests.kt
@@ -0,0 +1,408 @@
+/*
+ * 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.IpPrefix
+import android.net.LinkAddress
+import android.net.LinkProperties
+import android.net.LocalNetworkConfig
+import android.net.NetworkCapabilities
+import android.net.NetworkCapabilities.NET_CAPABILITY_DUN
+import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET
+import android.net.NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK
+import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED
+import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING
+import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED
+import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_VCN_MANAGED
+import android.net.NetworkCapabilities.TRANSPORT_CELLULAR
+import android.net.NetworkCapabilities.TRANSPORT_WIFI
+import android.net.NetworkRequest
+import android.net.NetworkScore
+import android.net.NetworkScore.KEEP_CONNECTED_FOR_TEST
+import android.net.NetworkScore.KEEP_CONNECTED_LOCAL_NETWORK
+import android.net.RouteInfo
+import android.os.Build
+import com.android.testutils.DevSdkIgnoreRule
+import com.android.testutils.DevSdkIgnoreRunner
+import com.android.testutils.RecorderCallback.CallbackEntry.Available
+import com.android.testutils.RecorderCallback.CallbackEntry.BlockedStatus
+import com.android.testutils.RecorderCallback.CallbackEntry.CapabilitiesChanged
+import com.android.testutils.RecorderCallback.CallbackEntry.LinkPropertiesChanged
+import com.android.testutils.RecorderCallback.CallbackEntry.Lost
+import com.android.testutils.TestableNetworkCallback
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.clearInvocations
+import org.mockito.Mockito.inOrder
+import org.mockito.Mockito.never
+import org.mockito.Mockito.timeout
+import org.mockito.Mockito.verify
+import kotlin.test.assertFailsWith
+
+private const val TIMEOUT_MS = 200L
+private const val MEDIUM_TIMEOUT_MS = 1_000L
+private const val LONG_TIMEOUT_MS = 5_000
+
+private fun nc(transport: Int, vararg caps: Int) = NetworkCapabilities.Builder().apply {
+    addTransportType(transport)
+    caps.forEach {
+        addCapability(it)
+    }
+    // Useful capabilities for everybody
+    addCapability(NET_CAPABILITY_NOT_RESTRICTED)
+    addCapability(NET_CAPABILITY_NOT_SUSPENDED)
+    addCapability(NET_CAPABILITY_NOT_ROAMING)
+    addCapability(NET_CAPABILITY_NOT_VCN_MANAGED)
+}.build()
+
+private fun lp(iface: String) = LinkProperties().apply {
+    interfaceName = iface
+    addLinkAddress(LinkAddress(LOCAL_IPV4_ADDRESS, 32))
+    addRoute(RouteInfo(IpPrefix("0.0.0.0/0"), null, null))
+}
+
+// This allows keeping all the networks connected without having to file individual requests
+// for them.
+private fun keepScore() = FromS(
+        NetworkScore.Builder().setKeepConnectedReason(KEEP_CONNECTED_FOR_TEST).build()
+)
+
+@RunWith(DevSdkIgnoreRunner::class)
+@DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.TIRAMISU)
+class CSLocalAgentTests : CSTest() {
+    @Test
+    fun testBadAgents() {
+        deps.setBuildSdk(VERSION_V)
+
+        assertFailsWith<IllegalArgumentException> {
+            Agent(nc = NetworkCapabilities.Builder()
+                    .addCapability(NET_CAPABILITY_LOCAL_NETWORK)
+                    .build(),
+                    lnc = null)
+        }
+        assertFailsWith<IllegalArgumentException> {
+            Agent(nc = NetworkCapabilities.Builder().build(),
+                    lnc = LocalNetworkConfig.Builder().build())
+        }
+    }
+
+    @Test
+    fun testStructuralConstraintViolation() {
+        deps.setBuildSdk(VERSION_V)
+
+        val cb = TestableNetworkCallback()
+        cm.requestNetwork(NetworkRequest.Builder()
+                .clearCapabilities()
+                .build(),
+                cb)
+        val agent = Agent(nc = NetworkCapabilities.Builder()
+                .addCapability(NET_CAPABILITY_LOCAL_NETWORK)
+                .build(),
+                lnc = LocalNetworkConfig.Builder().build())
+        agent.connect()
+        cb.expect<Available>(agent.network)
+        cb.expect<CapabilitiesChanged>(agent.network)
+        cb.expect<LinkPropertiesChanged>(agent.network)
+        cb.expect<BlockedStatus>(agent.network)
+        agent.sendNetworkCapabilities(NetworkCapabilities.Builder().build())
+        cb.expect<Lost>(agent.network)
+
+        val agent2 = Agent(nc = NetworkCapabilities.Builder()
+                .build(),
+                lnc = null)
+        agent2.connect()
+        cb.expect<Available>(agent2.network)
+        cb.expect<CapabilitiesChanged>(agent2.network)
+        cb.expect<LinkPropertiesChanged>(agent2.network)
+        cb.expect<BlockedStatus>(agent2.network)
+        agent2.sendNetworkCapabilities(NetworkCapabilities.Builder()
+                .addCapability(NET_CAPABILITY_LOCAL_NETWORK)
+                .build())
+        cb.expect<Lost>(agent2.network)
+    }
+
+    @Test
+    fun testUpdateLocalAgentConfig() {
+        deps.setBuildSdk(VERSION_V)
+
+        val cb = TestableNetworkCallback()
+        cm.requestNetwork(NetworkRequest.Builder()
+                .addCapability(NET_CAPABILITY_LOCAL_NETWORK)
+                .build(),
+                cb)
+
+        // Set up a local agent that should forward its traffic to the best DUN upstream.
+        val localAgent = Agent(
+                nc = nc(TRANSPORT_WIFI, NET_CAPABILITY_LOCAL_NETWORK),
+                lp = lp("local0"),
+                lnc = LocalNetworkConfig.Builder().build(),
+        )
+        localAgent.connect()
+
+        cb.expect<Available>(localAgent.network)
+        cb.expect<CapabilitiesChanged>(localAgent.network)
+        cb.expect<LinkPropertiesChanged>(localAgent.network)
+        cb.expect<BlockedStatus>(localAgent.network)
+
+        val newLnc = LocalNetworkConfig.Builder()
+                .setUpstreamSelector(NetworkRequest.Builder()
+                        .addTransportType(TRANSPORT_WIFI)
+                        .build())
+                .build()
+        localAgent.sendLocalNetworkConfig(newLnc)
+
+        localAgent.disconnect()
+    }
+
+    @Test
+    fun testUnregisterUpstreamAfterReplacement_SameIfaceName() {
+        doTestUnregisterUpstreamAfterReplacement(true)
+    }
+
+    @Test
+    fun testUnregisterUpstreamAfterReplacement_DifferentIfaceName() {
+        doTestUnregisterUpstreamAfterReplacement(false)
+    }
+
+    fun doTestUnregisterUpstreamAfterReplacement(sameIfaceName: Boolean) {
+        deps.setBuildSdk(VERSION_V)
+        val cb = TestableNetworkCallback()
+        cm.registerNetworkCallback(NetworkRequest.Builder().clearCapabilities().build(), cb)
+
+        // Set up a local agent that should forward its traffic to the best wifi upstream.
+        val localAgent = Agent(nc = nc(TRANSPORT_WIFI, NET_CAPABILITY_LOCAL_NETWORK),
+                lp = lp("local0"),
+                lnc = LocalNetworkConfig.Builder()
+                .setUpstreamSelector(NetworkRequest.Builder()
+                        .addTransportType(TRANSPORT_WIFI)
+                        .build())
+                .build(),
+                score = FromS(NetworkScore.Builder()
+                        .setKeepConnectedReason(KEEP_CONNECTED_LOCAL_NETWORK)
+                        .build())
+        )
+        localAgent.connect()
+
+        cb.expectAvailableCallbacks(localAgent.network, validated = false)
+
+        val wifiAgent = Agent(lp = lp("wifi0"),
+                nc = nc(TRANSPORT_WIFI, NET_CAPABILITY_INTERNET))
+        wifiAgent.connect()
+
+        cb.expectAvailableCallbacks(wifiAgent.network, validated = false)
+
+        clearInvocations(netd)
+        val inOrder = inOrder(netd)
+        wifiAgent.unregisterAfterReplacement(LONG_TIMEOUT_MS)
+        waitForIdle()
+        inOrder.verify(netd).ipfwdRemoveInterfaceForward("local0", "wifi0")
+        inOrder.verify(netd).networkDestroy(wifiAgent.network.netId)
+
+        val wifiIface2 = if (sameIfaceName) "wifi0" else "wifi1"
+        val wifiAgent2 = Agent(lp = lp(wifiIface2),
+                nc = nc(TRANSPORT_WIFI, NET_CAPABILITY_INTERNET))
+        wifiAgent2.connect()
+
+        cb.expectAvailableCallbacks(wifiAgent2.network, validated = false)
+        cb.expect<Lost> { it.network == wifiAgent.network }
+
+        inOrder.verify(netd).ipfwdAddInterfaceForward("local0", wifiIface2)
+        if (sameIfaceName) {
+            inOrder.verify(netd, never()).ipfwdRemoveInterfaceForward(any(), any())
+        }
+    }
+
+    @Test
+    fun testUnregisterUpstreamAfterReplacement_neverReplaced() {
+        deps.setBuildSdk(VERSION_V)
+        val cb = TestableNetworkCallback()
+        cm.registerNetworkCallback(NetworkRequest.Builder().clearCapabilities().build(), cb)
+
+        // Set up a local agent that should forward its traffic to the best wifi upstream.
+        val localAgent = Agent(nc = nc(TRANSPORT_WIFI, NET_CAPABILITY_LOCAL_NETWORK),
+                lp = lp("local0"),
+                lnc = LocalNetworkConfig.Builder()
+                        .setUpstreamSelector(NetworkRequest.Builder()
+                                .addTransportType(TRANSPORT_WIFI)
+                                .build())
+                        .build(),
+                score = FromS(NetworkScore.Builder()
+                        .setKeepConnectedReason(KEEP_CONNECTED_LOCAL_NETWORK)
+                        .build())
+        )
+        localAgent.connect()
+
+        cb.expectAvailableCallbacks(localAgent.network, validated = false)
+
+        val wifiAgent = Agent(lp = lp("wifi0"),
+                nc = nc(TRANSPORT_WIFI, NET_CAPABILITY_INTERNET))
+        wifiAgent.connect()
+
+        cb.expectAvailableCallbacksUnvalidated(wifiAgent)
+
+        clearInvocations(netd)
+        wifiAgent.unregisterAfterReplacement(TIMEOUT_MS.toInt())
+        waitForIdle()
+        verify(netd).networkDestroy(wifiAgent.network.netId)
+        verify(netd).ipfwdRemoveInterfaceForward("local0", "wifi0")
+
+        cb.expect<Lost> { it.network == wifiAgent.network }
+    }
+
+    @Test
+    fun testUnregisterLocalAgentAfterReplacement() {
+        deps.setBuildSdk(VERSION_V)
+
+        val localCb = TestableNetworkCallback()
+        cm.requestNetwork(NetworkRequest.Builder().clearCapabilities()
+                .addCapability(NET_CAPABILITY_LOCAL_NETWORK)
+                .build(),
+                localCb)
+
+        val cb = TestableNetworkCallback()
+        cm.registerNetworkCallback(NetworkRequest.Builder().clearCapabilities().build(), cb)
+
+        val localNc = nc(TRANSPORT_WIFI, NET_CAPABILITY_LOCAL_NETWORK)
+        val lnc = LocalNetworkConfig.Builder()
+                .setUpstreamSelector(NetworkRequest.Builder()
+                        .addTransportType(TRANSPORT_WIFI)
+                        .build())
+                .build()
+        val localScore = FromS(NetworkScore.Builder().build())
+
+        // Set up a local agent that should forward its traffic to the best wifi upstream.
+        val localAgent = Agent(nc = localNc, lp = lp("local0"), lnc = lnc, score = localScore)
+        localAgent.connect()
+
+        localCb.expectAvailableCallbacks(localAgent.network, validated = false)
+        cb.expectAvailableCallbacks(localAgent.network, validated = false)
+
+        val wifiAgent = Agent(lp = lp("wifi0"), nc = nc(TRANSPORT_WIFI, NET_CAPABILITY_INTERNET))
+        wifiAgent.connect()
+
+        cb.expectAvailableCallbacksUnvalidated(wifiAgent)
+
+        verify(netd).ipfwdAddInterfaceForward("local0", "wifi0")
+
+        localAgent.unregisterAfterReplacement(LONG_TIMEOUT_MS)
+
+        val localAgent2 = Agent(nc = localNc, lp = lp("local0"), lnc = lnc, score = localScore)
+        localAgent2.connect()
+
+        localCb.expectAvailableCallbacks(localAgent2.network, validated = false)
+        cb.expectAvailableCallbacks(localAgent2.network, validated = false)
+        cb.expect<Lost> { it.network == localAgent.network }
+    }
+
+    @Test
+    fun testDestroyedNetworkAsSelectedUpstream() {
+        deps.setBuildSdk(VERSION_V)
+        val cb = TestableNetworkCallback()
+        cm.registerNetworkCallback(NetworkRequest.Builder().clearCapabilities().build(), cb)
+
+        val wifiAgent = Agent(lp = lp("wifi0"), nc = nc(TRANSPORT_WIFI, NET_CAPABILITY_INTERNET))
+        wifiAgent.connect()
+        cb.expectAvailableCallbacksUnvalidated(wifiAgent)
+
+        // Set up a local agent that should forward its traffic to the best wifi upstream.
+        val localAgent = Agent(nc = nc(TRANSPORT_WIFI, NET_CAPABILITY_LOCAL_NETWORK),
+                lp = lp("local0"),
+                lnc = LocalNetworkConfig.Builder()
+                        .setUpstreamSelector(NetworkRequest.Builder()
+                                .addTransportType(TRANSPORT_WIFI)
+                                .build())
+                        .build(),
+                score = FromS(NetworkScore.Builder()
+                        .setKeepConnectedReason(KEEP_CONNECTED_LOCAL_NETWORK)
+                        .build())
+        )
+
+        // ...but destroy the wifi agent before connecting it
+        wifiAgent.unregisterAfterReplacement(LONG_TIMEOUT_MS)
+
+        localAgent.connect()
+        cb.expectAvailableCallbacks(localAgent.network, validated = false)
+
+        verify(netd).ipfwdAddInterfaceForward("local0", "wifi0")
+        verify(netd).ipfwdRemoveInterfaceForward("local0", "wifi0")
+    }
+
+    @Test
+    fun testForwardingRules() {
+        deps.setBuildSdk(VERSION_V)
+        // Set up a local agent that should forward its traffic to the best DUN upstream.
+        val lnc = LocalNetworkConfig.Builder()
+                .setUpstreamSelector(NetworkRequest.Builder()
+                        .addCapability(NET_CAPABILITY_DUN)
+                        .build())
+                .build()
+        val localAgent = Agent(nc = nc(TRANSPORT_WIFI, NET_CAPABILITY_LOCAL_NETWORK),
+                lp = lp("local0"),
+                lnc = lnc,
+                score = FromS(NetworkScore.Builder()
+                        .setKeepConnectedReason(KEEP_CONNECTED_LOCAL_NETWORK)
+                        .build())
+        )
+        localAgent.connect()
+
+        val wifiAgent = Agent(score = keepScore(), lp = lp("wifi0"),
+                nc = nc(TRANSPORT_WIFI, NET_CAPABILITY_INTERNET))
+        val cellAgentDun = Agent(score = keepScore(), lp = lp("cell0"),
+                nc = nc(TRANSPORT_CELLULAR, NET_CAPABILITY_INTERNET, NET_CAPABILITY_DUN))
+        val wifiAgentDun = Agent(score = keepScore(), lp = lp("wifi1"),
+                nc = nc(TRANSPORT_WIFI, NET_CAPABILITY_INTERNET, NET_CAPABILITY_DUN))
+
+        val inOrder = inOrder(netd)
+        inOrder.verify(netd, never()).ipfwdAddInterfaceForward(any(), any())
+
+        wifiAgent.connect()
+        inOrder.verify(netd, never()).ipfwdAddInterfaceForward(any(), any())
+
+        cellAgentDun.connect()
+        inOrder.verify(netd).ipfwdEnableForwarding(any())
+        inOrder.verify(netd).ipfwdAddInterfaceForward("local0", "cell0")
+
+        wifiAgentDun.connect()
+        inOrder.verify(netd).ipfwdRemoveInterfaceForward("local0", "cell0")
+        inOrder.verify(netd).ipfwdAddInterfaceForward("local0", "wifi1")
+
+        // Make sure sending the same config again doesn't do anything
+        repeat(5) {
+            localAgent.sendLocalNetworkConfig(lnc)
+        }
+        inOrder.verifyNoMoreInteractions()
+
+        wifiAgentDun.disconnect()
+        inOrder.verify(netd).ipfwdRemoveInterfaceForward("local0", "wifi1")
+        // This can take a little bit of time because it needs to wait for the rematch
+        inOrder.verify(netd, timeout(MEDIUM_TIMEOUT_MS)).ipfwdAddInterfaceForward("local0", "cell0")
+
+        cellAgentDun.disconnect()
+        inOrder.verify(netd).ipfwdRemoveInterfaceForward("local0", "cell0")
+        inOrder.verify(netd).ipfwdDisableForwarding(any())
+
+        val wifiAgentDun2 = Agent(score = keepScore(), lp = lp("wifi2"),
+                nc = nc(TRANSPORT_WIFI, NET_CAPABILITY_INTERNET, NET_CAPABILITY_DUN))
+        wifiAgentDun2.connect()
+        inOrder.verify(netd).ipfwdEnableForwarding(any())
+        inOrder.verify(netd).ipfwdAddInterfaceForward("local0", "wifi2")
+
+        localAgent.disconnect()
+        inOrder.verify(netd).ipfwdRemoveInterfaceForward("local0", "wifi2")
+        inOrder.verify(netd).ipfwdDisableForwarding(any())
+    }
+}
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 5ae9232..013a749 100644
--- a/tests/unit/java/com/android/server/connectivityservice/base/CSAgentWrapper.kt
+++ b/tests/unit/java/com/android/server/connectivityservice/base/CSAgentWrapper.kt
@@ -1,3 +1,19 @@
+/*
+ * 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.content.Context
@@ -5,6 +21,7 @@
 import android.net.INetworkMonitor
 import android.net.INetworkMonitorCallbacks
 import android.net.LinkProperties
+import android.net.LocalNetworkConfig
 import android.net.Network
 import android.net.NetworkAgent
 import android.net.NetworkAgentConfig
@@ -17,8 +34,8 @@
 import android.net.NetworkTestResultParcelable
 import android.net.networkstack.NetworkStackClientBase
 import android.os.HandlerThread
-import com.android.modules.utils.build.SdkLevel
 import com.android.testutils.RecorderCallback.CallbackEntry.Available
+import com.android.testutils.RecorderCallback.CallbackEntry.Lost
 import com.android.testutils.TestableNetworkCallback
 import org.mockito.ArgumentCaptor
 import org.mockito.ArgumentMatchers.any
@@ -31,6 +48,8 @@
 import kotlin.test.assertEquals
 import kotlin.test.fail
 
+const val SHORT_TIMEOUT_MS = 200L
+
 private inline fun <reified T> ArgumentCaptor() = ArgumentCaptor.forClass(T::class.java)
 
 private val agentCounter = AtomicInteger(1)
@@ -44,11 +63,13 @@
  */
 class CSAgentWrapper(
         val context: Context,
+        val deps: ConnectivityService.Dependencies,
         csHandlerThread: HandlerThread,
         networkStack: NetworkStackClientBase,
         nac: NetworkAgentConfig,
         val nc: NetworkCapabilities,
         val lp: LinkProperties,
+        val lnc: LocalNetworkConfig?,
         val score: FromS<NetworkScore>,
         val provider: NetworkProvider?
 ) : TestableNetworkCallback.HasNetwork {
@@ -78,9 +99,9 @@
                 nmCbCaptor.capture())
 
         // Create the actual agent. NetworkAgent is abstract, so make an anonymous subclass.
-        if (SdkLevel.isAtLeastS()) {
+        if (deps.isAtLeastS()) {
             agent = object : NetworkAgent(context, csHandlerThread.looper, TAG,
-                    nc, lp, score.value, nac, provider) {}
+                    nc, lp, lnc, score.value, nac, provider) {}
         } else {
             agent = object : NetworkAgent(context, csHandlerThread.looper, TAG,
                     nc, lp, 50 /* score */, nac, provider) {}
@@ -92,7 +113,7 @@
     }
 
     private fun onValidationRequested() {
-        if (SdkLevel.isAtLeastT()) {
+        if (deps.isAtLeastT()) {
             verify(networkMonitor).notifyNetworkConnectedParcel(any())
         } else {
             verify(networkMonitor).notifyNetworkConnected(any(), any())
@@ -109,9 +130,10 @@
 
     fun connect() {
         val mgr = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
-        val request = NetworkRequest.Builder().clearCapabilities()
-                .addTransportType(nc.transportTypes[0])
-                .build()
+        val request = NetworkRequest.Builder().apply {
+            clearCapabilities()
+            if (nc.transportTypes.isNotEmpty()) addTransportType(nc.transportTypes[0])
+        }.build()
         val cb = TestableNetworkCallback()
         mgr.registerNetworkCallback(request, cb)
         agent.markConnected()
@@ -131,4 +153,22 @@
         }
         mgr.unregisterNetworkCallback(cb)
     }
+
+    fun disconnect() {
+        val mgr = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+        val request = NetworkRequest.Builder().apply {
+            clearCapabilities()
+            if (nc.transportTypes.isNotEmpty()) addTransportType(nc.transportTypes[0])
+        }.build()
+        val cb = TestableNetworkCallback(timeoutMs = SHORT_TIMEOUT_MS)
+        mgr.registerNetworkCallback(request, cb)
+        cb.eventuallyExpect<Available> { it.network == agent.network }
+        agent.unregister()
+        cb.eventuallyExpect<Lost> { it.network == agent.network }
+    }
+
+    fun unregisterAfterReplacement(timeoutMs: Int) = agent.unregisterAfterReplacement(timeoutMs)
+
+    fun sendLocalNetworkConfig(lnc: LocalNetworkConfig) = agent.sendLocalNetworkConfig(lnc)
+    fun sendNetworkCapabilities(nc: NetworkCapabilities) = agent.sendNetworkCapabilities(nc)
 }
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 68613a6..0ccbfc3 100644
--- a/tests/unit/java/com/android/server/connectivityservice/base/CSTest.kt
+++ b/tests/unit/java/com/android/server/connectivityservice/base/CSTest.kt
@@ -1,3 +1,19 @@
+/*
+ * 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.content.BroadcastReceiver
@@ -10,27 +26,32 @@
 import android.net.ConnectivityManager
 import android.net.INetd
 import android.net.InetAddresses
-import android.net.IpPrefix
-import android.net.LinkAddress
 import android.net.LinkProperties
+import android.net.LocalNetworkConfig
 import android.net.NetworkAgentConfig
 import android.net.NetworkCapabilities
-import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING
 import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED
-import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_VCN_MANAGED
+import android.net.NetworkCapabilities.TRANSPORT_BLUETOOTH
+import android.net.NetworkCapabilities.TRANSPORT_CELLULAR
+import android.net.NetworkCapabilities.TRANSPORT_ETHERNET
+import android.net.NetworkCapabilities.TRANSPORT_TEST
+import android.net.NetworkCapabilities.TRANSPORT_VPN
+import android.net.NetworkCapabilities.TRANSPORT_WIFI
 import android.net.NetworkPolicyManager
 import android.net.NetworkProvider
 import android.net.NetworkScore
 import android.net.PacProxyManager
-import android.net.RouteInfo
 import android.net.networkstack.NetworkStackClientBase
+import android.os.BatteryStatsManager
 import android.os.Handler
 import android.os.HandlerThread
 import android.os.UserHandle
 import android.os.UserManager
 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
 import com.android.modules.utils.build.SdkLevel
 import com.android.networkstack.apishim.common.UnsupportedApiLevelException
@@ -41,6 +62,7 @@
 import com.android.server.connectivity.MultinetworkPolicyTracker
 import com.android.server.connectivity.MultinetworkPolicyTrackerTestDependencies
 import com.android.server.connectivity.ProxyTracker
+import com.android.testutils.visibleOnHandlerThread
 import com.android.testutils.waitForIdle
 import org.mockito.AdditionalAnswers.delegatesTo
 import org.mockito.Mockito.doAnswer
@@ -56,6 +78,25 @@
 
 open class FromS<Type>(val value: Type)
 
+internal const val VERSION_UNMOCKED = -1
+internal const val VERSION_R = 1
+internal const val VERSION_S = 2
+internal const val VERSION_T = 3
+internal const val VERSION_U = 4
+internal const val VERSION_V = 5
+internal const val VERSION_MAX = VERSION_V
+
+private fun NetworkCapabilities.getLegacyType() =
+        when (transportTypes.getOrElse(0) { TRANSPORT_WIFI }) {
+            TRANSPORT_BLUETOOTH -> ConnectivityManager.TYPE_BLUETOOTH
+            TRANSPORT_CELLULAR -> ConnectivityManager.TYPE_MOBILE
+            TRANSPORT_ETHERNET -> ConnectivityManager.TYPE_ETHERNET
+            TRANSPORT_TEST -> ConnectivityManager.TYPE_TEST
+            TRANSPORT_VPN -> ConnectivityManager.TYPE_VPN
+            TRANSPORT_WIFI -> ConnectivityManager.TYPE_WIFI
+            else -> ConnectivityManager.TYPE_NONE
+        }
+
 /**
  * Base class for tests testing ConnectivityService and its satellites.
  *
@@ -71,7 +112,7 @@
     init {
         if (!SdkLevel.isAtLeastS()) {
             throw UnsupportedApiLevelException("CSTest subclasses must be annotated to only " +
-                    "run on S+, e.g. @DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.R)");
+                    "run on S+, e.g. @DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.R)")
         }
     }
 
@@ -104,20 +145,22 @@
     val networkStack = mock<NetworkStackClientBase>()
     val csHandlerThread = HandlerThread("CSTestHandler")
     val sysResources = mock<Resources>().also { initMockedResources(it) }
-    val packageManager = makeMockPackageManager()
+    val packageManager = makeMockPackageManager(instrumentationContext)
     val connResources = makeMockConnResources(sysResources, packageManager)
 
+    val netd = mock<INetd>()
     val bpfNetMaps = mock<BpfNetMaps>()
     val clatCoordinator = mock<ClatCoordinator>()
     val proxyTracker = ProxyTracker(context, mock<Handler>(), 16 /* EVENT_PROXY_HAS_CHANGED */)
     val alarmManager = makeMockAlarmManager()
     val systemConfigManager = makeMockSystemConfigManager()
+    val batteryManager = BatteryStatsManager(mock<IBatteryStats>())
     val telephonyManager = mock<TelephonyManager>().also {
         doReturn(true).`when`(it).isDataCapable()
     }
 
     val deps = CSDeps()
-    val service = makeConnectivityService(context, deps).also { it.systemReadyInternal() }
+    val service = makeConnectivityService(context, netd, deps).also { it.systemReadyInternal() }
     val cm = ConnectivityManager(context, service)
     val csHandler = Handler(csHandlerThread.looper)
 
@@ -130,8 +173,10 @@
         override fun makeHandlerThread() = csHandlerThread
         override fun makeProxyTracker(context: Context, connServiceHandler: Handler) = proxyTracker
 
-        override fun makeCarrierPrivilegeAuthenticator(context: Context, tm: TelephonyManager) =
-                if (SdkLevel.isAtLeastT()) mock<CarrierPrivilegeAuthenticator>() else null
+        override fun makeCarrierPrivilegeAuthenticator(
+                context: Context,
+                tm: TelephonyManager
+        ) = if (SdkLevel.isAtLeastT()) mock<CarrierPrivilegeAuthenticator>() else null
 
         private inner class AOOKTDeps(c: Context) : AutomaticOnOffKeepaliveTracker.Dependencies(c) {
             override fun isTetheringFeatureNotChickenedOut(name: String): Boolean {
@@ -150,6 +195,43 @@
         // checking permissions.
         override fun isFeatureEnabled(context: Context?, name: String?) =
                 enabledFeatures[name] ?: fail("Unmocked feature $name, see CSTest.enabledFeatures")
+
+        // Mocked change IDs
+        private val enabledChangeIds = ArraySet<Long>()
+        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.
+            visibleOnHandlerThread(csHandler) {
+                if (enabled) {
+                    enabledChangeIds.add(changeId)
+                } else {
+                    enabledChangeIds.remove(changeId)
+                }
+            }
+        }
+
+        override fun isChangeEnabled(changeId: Long, pkg: String, user: UserHandle) =
+                changeId in enabledChangeIds
+        override fun isChangeEnabled(changeId: Long, uid: Int) =
+                changeId in enabledChangeIds
+
+        // In AOSP, build version codes can't always distinguish between some versions (e.g. at the
+        // time of this writing U == V). Define custom ones.
+        private var sdkLevel = VERSION_UNMOCKED
+        private val isSdkUnmocked get() = sdkLevel == VERSION_UNMOCKED
+
+        fun setBuildSdk(sdkLevel: Int) {
+            require(sdkLevel <= VERSION_MAX) {
+                "setBuildSdk must not be called with Build.VERSION constants but " +
+                        "CsTest.VERSION_* constants"
+            }
+            visibleOnHandlerThread(csHandler) { this.sdkLevel = sdkLevel }
+        }
+
+        override fun isAtLeastS() = if (isSdkUnmocked) super.isAtLeastS() else sdkLevel >= VERSION_S
+        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
     }
 
     inner class CSContext(base: Context) : BroadcastInterceptingContext(base) {
@@ -196,6 +278,7 @@
             Context.ACTIVITY_SERVICE -> activityManager
             Context.SYSTEM_CONFIG_SERVICE -> systemConfigManager
             Context.TELEPHONY_SERVICE -> telephonyManager
+            Context.BATTERY_STATS_SERVICE -> batteryManager
             Context.STATS_MANAGER -> null // Stats manager is final and can't be mocked
             else -> super.getSystemService(serviceName)
         }
@@ -204,29 +287,17 @@
     // Utility methods for subclasses to use
     fun waitForIdle() = csHandlerThread.waitForIdle(HANDLER_TIMEOUT_MS)
 
-    private fun emptyAgentConfig() = NetworkAgentConfig.Builder().build()
-    private fun defaultNc() = NetworkCapabilities.Builder()
-            // Add sensible defaults for agents that don't want to care
-            .addCapability(NET_CAPABILITY_NOT_SUSPENDED)
-            .addCapability(NET_CAPABILITY_NOT_ROAMING)
-            .addCapability(NET_CAPABILITY_NOT_VCN_MANAGED)
-            .build()
-    private fun defaultScore() = FromS<NetworkScore>(NetworkScore.Builder().build())
-    private fun defaultLp() = LinkProperties().apply {
-        addLinkAddress(LinkAddress(LOCAL_IPV4_ADDRESS, 32))
-        addRoute(RouteInfo(IpPrefix("0.0.0.0/0"), null, null))
-    }
-
     // Network agents. See CSAgentWrapper. This class contains utility methods to simplify
     // creation.
     fun Agent(
-            nac: NetworkAgentConfig = emptyAgentConfig(),
             nc: NetworkCapabilities = defaultNc(),
+            nac: NetworkAgentConfig = emptyAgentConfig(nc.getLegacyType()),
             lp: LinkProperties = defaultLp(),
+            lnc: LocalNetworkConfig? = null,
             score: FromS<NetworkScore> = defaultScore(),
             provider: NetworkProvider? = null
-    ) = CSAgentWrapper(context, csHandlerThread, networkStack, nac, nc, lp, score, provider)
-
+    ) = CSAgentWrapper(context, deps, csHandlerThread, networkStack,
+            nac, nc, lp, lnc, score, provider)
     fun Agent(vararg transports: Int, lp: LinkProperties = defaultLp()): CSAgentWrapper {
         val nc = NetworkCapabilities.Builder().apply {
             transports.forEach {
diff --git a/tests/unit/java/com/android/server/connectivityservice/base/CSTestHelpers.kt b/tests/unit/java/com/android/server/connectivityservice/base/CSTestHelpers.kt
index b8f2151..c1828b2 100644
--- a/tests/unit/java/com/android/server/connectivityservice/base/CSTestHelpers.kt
+++ b/tests/unit/java/com/android/server/connectivityservice/base/CSTestHelpers.kt
@@ -1,3 +1,19 @@
+/*
+ * 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.
+ */
+
 @file:JvmName("CsTestHelpers")
 
 package com.android.server
@@ -14,7 +30,15 @@
 import android.content.res.Resources
 import android.net.IDnsResolver
 import android.net.INetd
+import android.net.IpPrefix
+import android.net.LinkAddress
+import android.net.LinkProperties
+import android.net.NetworkAgentConfig
+import android.net.NetworkCapabilities
+import android.net.NetworkScore
+import android.net.RouteInfo
 import android.net.metrics.IpConnectivityLog
+import android.os.Binder
 import android.os.Handler
 import android.os.HandlerThread
 import android.os.SystemClock
@@ -31,19 +55,38 @@
 import com.android.server.connectivity.ConnectivityResources
 import org.mockito.ArgumentMatchers
 import org.mockito.ArgumentMatchers.any
+import org.mockito.ArgumentMatchers.anyInt
 import org.mockito.ArgumentMatchers.anyLong
 import org.mockito.ArgumentMatchers.anyString
 import org.mockito.ArgumentMatchers.argThat
 import org.mockito.ArgumentMatchers.eq
 import org.mockito.Mockito
 import org.mockito.Mockito.doAnswer
-import org.mockito.Mockito.doReturn
 import org.mockito.Mockito.doNothing
+import org.mockito.Mockito.doReturn
 import kotlin.test.fail
 
 internal inline fun <reified T> mock() = Mockito.mock(T::class.java)
 internal inline fun <reified T> any() = any(T::class.java)
 
+internal fun emptyAgentConfig(legacyType: Int) = NetworkAgentConfig.Builder()
+        .setLegacyType(legacyType)
+        .build()
+
+internal fun defaultNc() = NetworkCapabilities.Builder()
+        // Add sensible defaults for agents that don't want to care
+        .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED)
+        .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING)
+        .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VCN_MANAGED)
+        .build()
+
+internal fun defaultScore() = FromS(NetworkScore.Builder().build())
+
+internal fun defaultLp() = LinkProperties().apply {
+    addLinkAddress(LinkAddress(LOCAL_IPV4_ADDRESS, 32))
+    addRoute(RouteInfo(IpPrefix("0.0.0.0/0"), null, null))
+}
+
 internal fun makeMockContentResolver(context: Context) = MockContentResolver(context).apply {
     addProvider(Settings.AUTHORITY, FakeSettingsProvider())
 }
@@ -59,9 +102,22 @@
     }
 }
 
-internal fun makeMockPackageManager() = mock<PackageManager>().also { pm ->
+internal fun makeMockPackageManager(realContext: Context) = mock<PackageManager>().also { pm ->
     val supported = listOf(FEATURE_WIFI, FEATURE_WIFI_DIRECT, FEATURE_BLUETOOTH, FEATURE_ETHERNET)
     doReturn(true).`when`(pm).hasSystemFeature(argThat { supported.contains(it) })
+    val myPackageName = realContext.packageName
+    val myPackageInfo = realContext.packageManager.getPackageInfo(myPackageName,
+            PackageManager.GET_PERMISSIONS)
+    // Very high version code so that the checks for the module version will always
+    // say that it is recent enough. This is the most sensible default, but if some
+    // test needs to test with different version codes they can re-mock this with a
+    // different value.
+    myPackageInfo.longVersionCode = 9999999L
+    doReturn(arrayOf(myPackageName)).`when`(pm).getPackagesForUid(Binder.getCallingUid())
+    doReturn(myPackageInfo).`when`(pm).getPackageInfoAsUser(
+            eq(myPackageName), anyInt(), eq(UserHandle.getCallingUserId()))
+    doReturn(listOf(myPackageInfo)).`when`(pm)
+            .getInstalledPackagesAsUser(eq(PackageManager.GET_PERMISSIONS), anyInt())
 }
 
 internal fun makeMockConnResources(resources: Resources, pm: PackageManager) = mock<Context>().let {
@@ -129,12 +185,13 @@
 
 private val TEST_LINGER_DELAY_MS = 400
 private val TEST_NASCENT_DELAY_MS = 300
-internal fun makeConnectivityService(context: Context, deps: Dependencies) = ConnectivityService(
-        context,
-        mock<IDnsResolver>(),
-        mock<IpConnectivityLog>(),
-        mock<INetd>(),
-        deps).also {
-    it.mLingerDelayMs = TEST_LINGER_DELAY_MS
-    it.mNascentDelayMs = TEST_NASCENT_DELAY_MS
-}
+internal fun makeConnectivityService(context: Context, netd: INetd, deps: Dependencies) =
+        ConnectivityService(
+                context,
+                mock<IDnsResolver>(),
+                mock<IpConnectivityLog>(),
+                netd,
+                deps).also {
+            it.mLingerDelayMs = TEST_LINGER_DELAY_MS
+            it.mNascentDelayMs = TEST_NASCENT_DELAY_MS
+        }
diff --git a/tests/unit/java/com/android/server/net/NetworkStatsEventLoggerTest.kt b/tests/unit/java/com/android/server/net/NetworkStatsEventLoggerTest.kt
new file mode 100644
index 0000000..9f2d4d3
--- /dev/null
+++ b/tests/unit/java/com/android/server/net/NetworkStatsEventLoggerTest.kt
@@ -0,0 +1,173 @@
+/*
+ * 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
+
+import android.util.IndentingPrintWriter
+import com.android.server.net.NetworkStatsEventLogger.MAX_POLL_REASON
+import com.android.server.net.NetworkStatsEventLogger.POLL_REASON_DUMPSYS
+import com.android.server.net.NetworkStatsEventLogger.POLL_REASON_FORCE_UPDATE
+import com.android.server.net.NetworkStatsEventLogger.POLL_REASON_GLOBAL_ALERT
+import com.android.server.net.NetworkStatsEventLogger.POLL_REASON_NETWORK_STATUS_CHANGED
+import com.android.server.net.NetworkStatsEventLogger.POLL_REASON_OPEN_SESSION
+import com.android.server.net.NetworkStatsEventLogger.POLL_REASON_PERIODIC
+import com.android.server.net.NetworkStatsEventLogger.POLL_REASON_RAT_CHANGED
+import com.android.server.net.NetworkStatsEventLogger.POLL_REASON_REG_CALLBACK
+import com.android.server.net.NetworkStatsEventLogger.PollEvent
+import com.android.server.net.NetworkStatsEventLogger.PollEvent.pollReasonNameOf
+import com.android.testutils.DevSdkIgnoreRunner
+import java.io.StringWriter
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+
+const val TEST_PERSIST_FLAG = 0x101
+
+@RunWith(DevSdkIgnoreRunner::class)
+class NetworkStatsEventLoggerTest {
+    val logger = NetworkStatsEventLogger()
+    val stringWriter = TestStringWriter()
+    val pw = IndentingPrintWriter(stringWriter)
+
+    @Test
+    fun testDump_invalid() {
+        // Verify it won't crash.
+        logger.dump(pw)
+        // Clear output buffer.
+        stringWriter.getOutputAndClear()
+
+        // Verify log invalid event throws. And nothing output in the dump.
+        val invalidReasons = listOf(-1, MAX_POLL_REASON + 1)
+        invalidReasons.forEach {
+            assertFailsWith<IllegalArgumentException> {
+                logger.logPollEvent(TEST_PERSIST_FLAG, PollEvent(it))
+            }
+            logger.dumpRecentPollEvents(pw)
+            val output = stringWriter.getOutputAndClear()
+            assertStringNotContains(output, pollReasonNameOf(it))
+        }
+    }
+
+    @Test
+    fun testDump_valid() {
+        // Choose arbitrary set of reasons for testing.
+        val loggedReasons = listOf(
+            POLL_REASON_GLOBAL_ALERT,
+            POLL_REASON_FORCE_UPDATE,
+            POLL_REASON_DUMPSYS,
+            POLL_REASON_PERIODIC,
+            POLL_REASON_RAT_CHANGED
+        )
+        val nonLoggedReasons = listOf(
+            POLL_REASON_NETWORK_STATUS_CHANGED,
+            POLL_REASON_OPEN_SESSION,
+            POLL_REASON_REG_CALLBACK)
+
+        // Add some valid records.
+        loggedReasons.forEach {
+            logger.logPollEvent(TEST_PERSIST_FLAG, PollEvent(it))
+        }
+
+        // Collect dumps.
+        logger.dumpRecentPollEvents(pw)
+        val outputRecentEvents = stringWriter.getOutputAndClear()
+        logger.dumpPollCountsPerReason(pw)
+        val outputCountsPerReason = stringWriter.getOutputAndClear()
+
+        // Verify the output contains at least necessary information.
+        loggedReasons.forEach {
+            // Verify all events are shown in the recent event dump.
+            val eventString = PollEvent(it).toString()
+            assertStringContains(outputRecentEvents, TEST_PERSIST_FLAG.toString())
+            assertStringContains(eventString, pollReasonNameOf(it))
+            assertStringContains(outputRecentEvents, eventString)
+            // Verify counts are 1 for each reason.
+            assertCountForReason(outputCountsPerReason, it, 1)
+        }
+
+        // Verify the output remains untouched for other reasons.
+        nonLoggedReasons.forEach {
+            assertStringNotContains(outputRecentEvents, PollEvent(it).toString())
+            assertCountForReason(outputCountsPerReason, it, 0)
+        }
+    }
+
+    @Test
+    fun testDump_maxEventLogs() {
+        // Choose arbitrary reason.
+        val reasonToBeTested = POLL_REASON_PERIODIC
+        val repeatCount = NetworkStatsEventLogger.MAX_EVENTS_LOGS * 2
+
+        // Collect baseline.
+        logger.dumpRecentPollEvents(pw)
+        val lineCountBaseLine = getLineCount(stringWriter.getOutputAndClear())
+
+        repeat(repeatCount) {
+            logger.logPollEvent(TEST_PERSIST_FLAG, PollEvent(reasonToBeTested))
+        }
+
+        // Collect dump.
+        logger.dumpRecentPollEvents(pw)
+        val lineCountAfterTest = getLineCount(stringWriter.getOutputAndClear())
+
+        // Verify line count increment is limited.
+        assertEquals(
+            NetworkStatsEventLogger.MAX_EVENTS_LOGS,
+            lineCountAfterTest - lineCountBaseLine
+        )
+
+        // Verify count per reason increased for the testing reason.
+        logger.dumpPollCountsPerReason(pw)
+        val outputCountsPerReason = stringWriter.getOutputAndClear()
+        for (reason in 0..MAX_POLL_REASON) {
+            assertCountForReason(
+                outputCountsPerReason,
+                reason,
+                if (reason == reasonToBeTested) repeatCount else 0
+            )
+        }
+    }
+
+    private fun getLineCount(multilineString: String) = multilineString.lines().size
+
+    private fun assertStringContains(got: String, want: String) {
+        assertTrue(got.contains(want), "Wanted: $want, but got: $got")
+    }
+
+    private fun assertStringNotContains(got: String, unwant: String) {
+        assertFalse(got.contains(unwant), "Unwanted: $unwant, but got: $got")
+    }
+
+    /**
+     * Assert the reason and the expected count are at the same line.
+     */
+    private fun assertCountForReason(dump: String, reason: Int, expectedCount: Int) {
+        // Matches strings like "GLOBAL_ALERT: 50" but not too strict since the format might change.
+        val regex = Regex(pollReasonNameOf(reason) + "[^0-9]+" + expectedCount)
+        assertEquals(
+            1,
+            regex.findAll(dump).count(),
+            "Unexpected output: $dump " + " for reason: " + pollReasonNameOf(reason)
+        )
+    }
+
+    class TestStringWriter : StringWriter() {
+        fun getOutputAndClear() = toString().also { buffer.setLength(0) }
+    }
+}
diff --git a/tests/unit/java/com/android/server/net/NetworkStatsObserversTest.java b/tests/unit/java/com/android/server/net/NetworkStatsObserversTest.java
index 292f77e..e62ac74 100644
--- a/tests/unit/java/com/android/server/net/NetworkStatsObserversTest.java
+++ b/tests/unit/java/com/android/server/net/NetworkStatsObserversTest.java
@@ -59,6 +59,7 @@
 import com.android.testutils.DevSdkIgnoreRunner;
 import com.android.testutils.HandlerUtils;
 
+import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -124,7 +125,7 @@
     public void setUp() throws Exception {
         MockitoAnnotations.initMocks(this);
 
-        mObserverHandlerThread = new HandlerThread("HandlerThread");
+        mObserverHandlerThread = new HandlerThread("NetworkStatsObserversTest");
         mObserverHandlerThread.start();
         final Looper observerLooper = mObserverHandlerThread.getLooper();
         mStatsObservers = new NetworkStatsObservers() {
@@ -139,6 +140,14 @@
         mUsageCallback = new TestableUsageCallback(mUsageCallbackBinder);
     }
 
+    @After
+    public void tearDown() throws Exception {
+        if (mObserverHandlerThread != null) {
+            mObserverHandlerThread.quitSafely();
+            mObserverHandlerThread.join();
+        }
+    }
+
     @Test
     public void testRegister_thresholdTooLow_setsDefaultThreshold() throws Exception {
         final long thresholdTooLowBytes = 1L;
diff --git a/tests/unit/java/com/android/server/net/NetworkStatsServiceTest.java b/tests/unit/java/com/android/server/net/NetworkStatsServiceTest.java
index 9453617..92a5b64 100644
--- a/tests/unit/java/com/android/server/net/NetworkStatsServiceTest.java
+++ b/tests/unit/java/com/android/server/net/NetworkStatsServiceTest.java
@@ -64,6 +64,8 @@
 import static android.text.format.DateUtils.MINUTE_IN_MILLIS;
 import static android.text.format.DateUtils.WEEK_IN_MILLIS;
 
+import static com.android.server.net.NetworkStatsEventLogger.POLL_REASON_RAT_CHANGED;
+import static com.android.server.net.NetworkStatsEventLogger.PollEvent.pollReasonNameOf;
 import static com.android.server.net.NetworkStatsService.ACTION_NETWORK_STATS_POLL;
 import static com.android.server.net.NetworkStatsService.NETSTATS_IMPORT_ATTEMPTS_COUNTER_NAME;
 import static com.android.server.net.NetworkStatsService.NETSTATS_IMPORT_FALLBACKS_COUNTER_NAME;
@@ -118,6 +120,7 @@
 import android.os.Handler;
 import android.os.HandlerThread;
 import android.os.IBinder;
+import android.os.Looper;
 import android.os.PowerManager;
 import android.os.SimpleClock;
 import android.provider.Settings;
@@ -282,6 +285,7 @@
     private @Mock PersistentInt mImportLegacyFallbacksCounter;
     private @Mock Resources mResources;
     private Boolean mIsDebuggable;
+    private HandlerThread mObserverHandlerThread;
 
     private class MockContext extends BroadcastInterceptingContext {
         private final Context mBaseContext;
@@ -363,10 +367,23 @@
         PowerManager.WakeLock wakeLock =
                 powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, TAG);
 
-        mHandlerThread = new HandlerThread("HandlerThread");
+        mHandlerThread = new HandlerThread("NetworkStatsServiceTest-HandlerThread");
         final NetworkStatsService.Dependencies deps = makeDependencies();
+        // Create a separate thread for observers to run on. This thread cannot be the same
+        // as the handler thread, because the observer callback is fired on this thread, and
+        // it should not be blocked by client code. Additionally, creating the observers
+        // object requires a looper, which can only be obtained after a thread has been started.
+        mObserverHandlerThread = new HandlerThread("NetworkStatsServiceTest-ObserversThread");
+        mObserverHandlerThread.start();
+        final Looper observerLooper = mObserverHandlerThread.getLooper();
+        final NetworkStatsObservers statsObservers = new NetworkStatsObservers() {
+            @Override
+            protected Looper getHandlerLooperLocked() {
+                return observerLooper;
+            }
+        };
         mService = new NetworkStatsService(mServiceContext, mNetd, mAlarmManager, wakeLock,
-                mClock, mSettings, mStatsFactory, new NetworkStatsObservers(), deps);
+                mClock, mSettings, mStatsFactory, statsObservers, deps);
 
         mElapsedRealtime = 0L;
 
@@ -525,6 +542,11 @@
                     IBpfMap<CookieTagMapKey, CookieTagMapValue> cookieTagMap, Handler handler) {
                 return mSkDestroyListener;
             }
+
+            @Override
+            public boolean supportEventLogger(@NonNull Context cts) {
+                return true;
+            }
         };
     }
 
@@ -538,8 +560,14 @@
         mSession.close();
         mService = null;
 
-        mHandlerThread.quitSafely();
-        mHandlerThread.join();
+        if (mHandlerThread != null) {
+            mHandlerThread.quitSafely();
+            mHandlerThread.join();
+        }
+        if (mObserverHandlerThread != null) {
+            mObserverHandlerThread.quitSafely();
+            mObserverHandlerThread.join();
+        }
     }
 
     private void initWifiStats(NetworkStateSnapshot snapshot) throws Exception {
@@ -2674,4 +2702,14 @@
         doReturn(null).when(mBpfInterfaceMapUpdater).getIfNameByIndex(10 /* index */);
         doTestDumpIfaceStatsMap("unknown");
     }
+
+    // Basic test to ensure event logger dump is called.
+    // Note that tests to ensure detailed correctness is done in the dedicated tests.
+    // See NetworkStatsEventLoggerTest.
+    @Test
+    public void testDumpEventLogger() {
+        setMobileRatTypeAndWaitForIdle(TelephonyManager.NETWORK_TYPE_UMTS);
+        final String dump = getDump();
+        assertDumpContains(dump, pollReasonNameOf(POLL_REASON_RAT_CHANGED));
+    }
 }
diff --git a/thread/TEST_MAPPING b/thread/TEST_MAPPING
index 17a74f6..3eaebfa 100644
--- a/thread/TEST_MAPPING
+++ b/thread/TEST_MAPPING
@@ -1,7 +1,5 @@
 {
-  // TODO (b/297729075): graduate this test to presubmit once it meets the SLO requirements.
-  // See go/test-mapping-slo-guide
-  "postsubmit": [
+  "presubmit": [
     {
       "name": "CtsThreadNetworkTestCases"
     }
diff --git a/thread/flags/thread_base.aconfig b/thread/flags/thread_base.aconfig
new file mode 100644
index 0000000..bf1f288
--- /dev/null
+++ b/thread/flags/thread_base.aconfig
@@ -0,0 +1,8 @@
+package: "com.android.net.thread.flags"
+
+flag {
+    name: "thread_enabled"
+    namespace: "thread_network"
+    description: "Controls whether the Android Thread feature is enabled"
+    bug: "301473012"
+}
diff --git a/thread/framework/java/android/net/thread/ActiveOperationalDataset.aidl b/thread/framework/java/android/net/thread/ActiveOperationalDataset.aidl
new file mode 100644
index 0000000..8bf12a4
--- /dev/null
+++ b/thread/framework/java/android/net/thread/ActiveOperationalDataset.aidl
@@ -0,0 +1,19 @@
+/*
+ * Copyright 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.thread;
+
+parcelable ActiveOperationalDataset;
diff --git a/thread/framework/java/android/net/thread/ActiveOperationalDataset.java b/thread/framework/java/android/net/thread/ActiveOperationalDataset.java
new file mode 100644
index 0000000..c9b047a
--- /dev/null
+++ b/thread/framework/java/android/net/thread/ActiveOperationalDataset.java
@@ -0,0 +1,1165 @@
+/*
+ * 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.thread;
+
+import static android.net.thread.ActiveOperationalDataset.SecurityPolicy.DEFAULT_ROTATION_TIME_HOURS;
+
+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 java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Objects.requireNonNull;
+
+import android.annotation.FlaggedApi;
+import android.annotation.IntRange;
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.annotation.Size;
+import android.annotation.SystemApi;
+import android.net.IpPrefix;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.util.SparseArray;
+
+import com.android.internal.annotations.VisibleForTesting;
+
+import java.io.ByteArrayOutputStream;
+import java.net.Inet6Address;
+import java.net.UnknownHostException;
+import java.security.SecureRandom;
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Random;
+
+/**
+ * Data interface for managing a Thread Active Operational Dataset.
+ *
+ * <p>An example usage of creating an Active Operational Dataset with random parameters:
+ *
+ * <pre>{@code
+ * ActiveOperationalDataset activeDataset = ActiveOperationalDataset.createRandomDataset();
+ * }</pre>
+ *
+ * <p>or random Dataset with customized Network Name:
+ *
+ * <pre>{@code
+ * ActiveOperationalDataset activeDataset =
+ *         new ActiveOperationalDataset.Builder(ActiveOperationalDataset.createRandomDataset())
+ *                 .setNetworkName("MyThreadNet").build();
+ * }</pre>
+ *
+ * <p>If the Active Operational Dataset is already known as <a
+ * href="https://www.threadgroup.org">Thread TLVs</a>, you can simply use:
+ *
+ * <pre>{@code
+ * ActiveOperationalDataset activeDataset = ActiveOperationalDataset.fromThreadTlvs(datasetTlvs);
+ * }</pre>
+ *
+ * @hide
+ */
+@FlaggedApi(ThreadNetworkFlags.FLAG_THREAD_ENABLED)
+@SystemApi
+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;
+
+    private static final byte MESH_LOCAL_PREFIX_FIRST_BYTE = (byte) 0xfd;
+    private static final int LENGTH_CHANNEL = 3;
+    private static final int LENGTH_PAN_ID = 2;
+
+    @NonNull
+    public static final Creator<ActiveOperationalDataset> CREATOR =
+            new Creator<>() {
+                @Override
+                public ActiveOperationalDataset createFromParcel(Parcel in) {
+                    return ActiveOperationalDataset.fromThreadTlvs(in.createByteArray());
+                }
+
+                @Override
+                public ActiveOperationalDataset[] newArray(int size) {
+                    return new ActiveOperationalDataset[size];
+                }
+            };
+
+    private final OperationalDatasetTimestamp mActiveTimestamp;
+    private final String mNetworkName;
+    private final byte[] mExtendedPanId;
+    private final int mPanId;
+    private final int mChannel;
+    private final int mChannelPage;
+    private final SparseArray<byte[]> mChannelMask;
+    private final byte[] mPskc;
+    private final byte[] mNetworkKey;
+    private final IpPrefix mMeshLocalPrefix;
+    private final SecurityPolicy mSecurityPolicy;
+    private final SparseArray<byte[]> mUnknownTlvs;
+
+    private ActiveOperationalDataset(Builder builder) {
+        this(
+                requireNonNull(builder.mActiveTimestamp),
+                requireNonNull(builder.mNetworkName),
+                requireNonNull(builder.mExtendedPanId),
+                requireNonNull(builder.mPanId),
+                requireNonNull(builder.mChannelPage),
+                requireNonNull(builder.mChannel),
+                requireNonNull(builder.mChannelMask),
+                requireNonNull(builder.mPskc),
+                requireNonNull(builder.mNetworkKey),
+                requireNonNull(builder.mMeshLocalPrefix),
+                requireNonNull(builder.mSecurityPolicy),
+                requireNonNull(builder.mUnknownTlvs));
+    }
+
+    private ActiveOperationalDataset(
+            OperationalDatasetTimestamp activeTimestamp,
+            String networkName,
+            byte[] extendedPanId,
+            int panId,
+            int channelPage,
+            int channel,
+            SparseArray<byte[]> channelMask,
+            byte[] pskc,
+            byte[] networkKey,
+            IpPrefix meshLocalPrefix,
+            SecurityPolicy securityPolicy,
+            SparseArray<byte[]> unknownTlvs) {
+        this.mActiveTimestamp = activeTimestamp;
+        this.mNetworkName = networkName;
+        this.mExtendedPanId = extendedPanId.clone();
+        this.mPanId = panId;
+        this.mChannel = channel;
+        this.mChannelPage = channelPage;
+        this.mChannelMask = deepCloneSparseArray(channelMask);
+        this.mPskc = pskc.clone();
+        this.mNetworkKey = networkKey.clone();
+        this.mMeshLocalPrefix = meshLocalPrefix;
+        this.mSecurityPolicy = securityPolicy;
+        this.mUnknownTlvs = deepCloneSparseArray(unknownTlvs);
+    }
+
+    /**
+     * Creates a new {@link ActiveOperationalDataset} object from a series of Thread TLVs.
+     *
+     * <p>{@code tlvs} can be obtained from the value of a Thread Active Operational Dataset TLV
+     * (see the <a href="https://www.threadgroup.org/support#specifications">Thread
+     * specification</a> for the definition) or the return value of {@link #toThreadTlvs}.
+     *
+     * @param tlvs a series of Thread TLVs which contain the Active Operational Dataset
+     * @return the decoded Active Operational Dataset
+     * @throws IllegalArgumentException if {@code tlvs} is malformed or the length is larger than
+     *     {@link LENGTH_MAX_DATASET_TLVS}
+     */
+    @NonNull
+    public static ActiveOperationalDataset fromThreadTlvs(@NonNull byte[] tlvs) {
+        requireNonNull(tlvs, "tlvs cannot be null");
+        if (tlvs.length > LENGTH_MAX_DATASET_TLVS) {
+            throw new IllegalArgumentException(
+                    String.format(
+                            "tlvs length exceeds max length %d (actual is %d)",
+                            LENGTH_MAX_DATASET_TLVS, tlvs.length));
+        }
+
+        Builder builder = new Builder();
+        int i = 0;
+        while (i < tlvs.length) {
+            int type = tlvs[i++] & 0xff;
+            if (i >= tlvs.length) {
+                throw new IllegalArgumentException(
+                        String.format(
+                                "Found TLV type %d at end of operational dataset with length %d",
+                                type, tlvs.length));
+            }
+
+            int length = tlvs[i++] & 0xff;
+            if (i + length > tlvs.length) {
+                throw new IllegalArgumentException(
+                        String.format(
+                                "Found TLV type %d with length %d which exceeds the remaining data"
+                                        + " in the operational dataset with length %d",
+                                type, length, tlvs.length));
+            }
+
+            initWithTlv(builder, type, Arrays.copyOfRange(tlvs, i, i + length));
+            i += length;
+        }
+        try {
+            return builder.build();
+        } catch (IllegalStateException e) {
+            throw new IllegalArgumentException(
+                    "Failed to build the ActiveOperationalDataset object", e);
+        }
+    }
+
+    private static void initWithTlv(Builder builder, int type, byte[] value) {
+        // The max length of the dataset is 254 bytes, so the max length of a single TLV value is
+        // 252 (254 - 1 - 1)
+        if (value.length > LENGTH_MAX_DATASET_TLVS - 2) {
+            throw new IllegalArgumentException(
+                    String.format(
+                            "Length of TLV %d exceeds %d (actualLength = %d)",
+                            (type & 0xff), LENGTH_MAX_DATASET_TLVS - 2, value.length));
+        }
+
+        switch (type) {
+            case TYPE_CHANNEL:
+                checkArgument(
+                        value.length == LENGTH_CHANNEL,
+                        "Invalid channel (length = %d, expectedLength = %d)",
+                        value.length,
+                        LENGTH_CHANNEL);
+                builder.setChannel((value[0] & 0xff), ((value[1] & 0xff) << 8) | (value[2] & 0xff));
+                break;
+            case TYPE_PAN_ID:
+                checkArgument(
+                        value.length == LENGTH_PAN_ID,
+                        "Invalid PAN ID (length = %d, expectedLength = %d)",
+                        value.length,
+                        LENGTH_PAN_ID);
+                builder.setPanId(((value[0] & 0xff) << 8) | (value[1] & 0xff));
+                break;
+            case TYPE_EXTENDED_PAN_ID:
+                builder.setExtendedPanId(value);
+                break;
+            case TYPE_NETWORK_NAME:
+                builder.setNetworkName(new String(value, UTF_8));
+                break;
+            case TYPE_PSKC:
+                builder.setPskc(value);
+                break;
+            case TYPE_NETWORK_KEY:
+                builder.setNetworkKey(value);
+                break;
+            case TYPE_MESH_LOCAL_PREFIX:
+                builder.setMeshLocalPrefix(value);
+                break;
+            case TYPE_SECURITY_POLICY:
+                builder.setSecurityPolicy(SecurityPolicy.fromTlvValue(value));
+                break;
+            case TYPE_ACTIVE_TIMESTAMP:
+                builder.setActiveTimestamp(OperationalDatasetTimestamp.fromTlvValue(value));
+                break;
+            case TYPE_CHANNEL_MASK:
+                builder.setChannelMask(decodeChannelMask(value));
+                break;
+            default:
+                builder.addUnknownTlv(type & 0xff, value);
+                break;
+        }
+    }
+
+    private static SparseArray<byte[]> decodeChannelMask(byte[] tlvValue) {
+        SparseArray<byte[]> channelMask = new SparseArray<>();
+        int i = 0;
+        while (i < tlvValue.length) {
+            int channelPage = tlvValue[i++] & 0xff;
+            if (i >= tlvValue.length) {
+                throw new IllegalArgumentException(
+                        "Invalid channel mask - channel mask length is missing");
+            }
+
+            int maskLength = tlvValue[i++] & 0xff;
+            if (i + maskLength > tlvValue.length) {
+                throw new IllegalArgumentException(
+                        String.format(
+                                "Invalid channel mask - channel mask is incomplete "
+                                        + "(offset = %d, length = %d, totalLength = %d)",
+                                i, maskLength, tlvValue.length));
+            }
+
+            channelMask.put(channelPage, Arrays.copyOfRange(tlvValue, i, i + maskLength));
+            i += maskLength;
+        }
+        return channelMask;
+    }
+
+    private static void encodeChannelMask(
+            SparseArray<byte[]> channelMask, ByteArrayOutputStream outputStream) {
+        ByteArrayOutputStream entryStream = new ByteArrayOutputStream();
+
+        for (int i = 0; i < channelMask.size(); i++) {
+            int key = channelMask.keyAt(i);
+            byte[] value = channelMask.get(key);
+            entryStream.write(key);
+            entryStream.write(value.length);
+            entryStream.write(value, 0, value.length);
+        }
+
+        byte[] entries = entryStream.toByteArray();
+
+        outputStream.write(TYPE_CHANNEL_MASK);
+        outputStream.write(entries.length);
+        outputStream.write(entries, 0, entries.length);
+    }
+
+    /**
+     * Creates a new {@link ActiveOperationalDataset} object with randomized or default parameters.
+     *
+     * <p>The randomized (or default) value for each parameter:
+     *
+     * <ul>
+     *   <li>{@code Active Timestamp} defaults to {@code new OperationalDatasetTimestamp(1, 0,
+     *       false)}
+     *   <li>{@code Network Name} defaults to "THREAD-PAN-<PAN ID decimal>", for example
+     *       "THREAD-PAN-12345"
+     *   <li>{@code Extended PAN ID} filled with randomly generated bytes
+     *   <li>{@code PAN ID} randomly generated integer in range of [0, 0xfffe]
+     *   <li>{@code Channel Page} defaults to {@link #CHANNEL_PAGE_24_GHZ}
+     *   <li>{@code Channel} randomly selected channel in range of [{@link #CHANNEL_MIN_24_GHZ},
+     *       {@link #CHANNEL_MAX_24_GHZ}]
+     *   <li>{@code Channel Mask} all bits from {@link #CHANNEL_MIN_24_GHZ} to {@link
+     *       #CHANNEL_MAX_24_GHZ} are set to {@code true}
+     *   <li>{@code PSKc} filled with bytes generated by secure random generator
+     *   <li>{@code Network Key} filled with bytes generated by secure random generator
+     *   <li>{@code Mesh-local Prefix} filled with randomly generated bytes except that the first
+     *       byte is always set to {@code 0xfd}
+     *   <li>{@code Security Policy} defaults to {@code new SecurityPolicy(
+     *       DEFAULT_ROTATION_TIME_HOURS, new byte[]{(byte)0xff, (byte)0xf8})}. This is the default
+     *       values required by the Thread 1.2 specification
+     * </ul>
+     *
+     * <p>This method is the recommended way to create a randomized operational dataset for a new
+     * Thread network. It may be desired to change one or more of the generated value(s). For
+     * example, to use a more meaningful Network Name. To do that, create a new {@link Builder}
+     * object from this dataset with {@link Builder#Builder(ActiveOperationalDataset)} and override
+     * the value with the setters of {@link Builder}.
+     *
+     * <p>Note that it's highly discouraged to change the randomly generated Extended PAN ID,
+     * Network Key or PSKc, as it will compromise the security of a Thread network.
+     */
+    @NonNull
+    public static ActiveOperationalDataset createRandomDataset() {
+        return createRandomDataset(new Random(Instant.now().toEpochMilli()), new SecureRandom());
+    }
+
+    /** @hide */
+    @VisibleForTesting
+    public static ActiveOperationalDataset createRandomDataset(
+            Random random, SecureRandom secureRandom) {
+        int panId = random.nextInt(/* bound= */ 0xffff);
+        byte[] meshLocalPrefix = newRandomBytes(random, LENGTH_MESH_LOCAL_PREFIX_BITS / 8);
+        meshLocalPrefix[0] = MESH_LOCAL_PREFIX_FIRST_BYTE;
+
+        SparseArray<byte[]> channelMask = new SparseArray<>(1);
+        channelMask.put(CHANNEL_PAGE_24_GHZ, new byte[] {0x00, 0x1f, (byte) 0xff, (byte) 0xe0});
+
+        return new Builder()
+                .setActiveTimestamp(
+                        new OperationalDatasetTimestamp(
+                                /* seconds= */ 1,
+                                /* ticks= */ 0,
+                                /* isAuthoritativeSource= */ false))
+                .setExtendedPanId(newRandomBytes(random, LENGTH_EXTENDED_PAN_ID))
+                .setPanId(panId)
+                .setNetworkName("THREAD-PAN-" + panId)
+                .setChannel(
+                        CHANNEL_PAGE_24_GHZ,
+                        random.nextInt(CHANNEL_MAX_24_GHZ - CHANNEL_MIN_24_GHZ + 1)
+                                + CHANNEL_MIN_24_GHZ)
+                .setChannelMask(channelMask)
+                .setPskc(newRandomBytes(secureRandom, LENGTH_PSKC))
+                .setNetworkKey(newRandomBytes(secureRandom, LENGTH_NETWORK_KEY))
+                .setMeshLocalPrefix(meshLocalPrefix)
+                .setSecurityPolicy(
+                        new SecurityPolicy(
+                                DEFAULT_ROTATION_TIME_HOURS, new byte[] {(byte) 0xff, (byte) 0xf8}))
+                .build();
+    }
+
+    private static byte[] newRandomBytes(Random random, int length) {
+        byte[] result = new byte[length];
+        random.nextBytes(result);
+        return result;
+    }
+
+    private static boolean areByteSparseArraysEqual(
+            @NonNull SparseArray<byte[]> first, @NonNull SparseArray<byte[]> second) {
+        if (first == second) {
+            return true;
+        } else if (first == null || second == null) {
+            return false;
+        } else if (first.size() != second.size()) {
+            return false;
+        } else {
+            for (int i = 0; i < first.size(); i++) {
+                int firstKey = first.keyAt(i);
+                int secondKey = second.keyAt(i);
+                if (firstKey != secondKey) {
+                    return false;
+                }
+
+                byte[] firstValue = first.valueAt(i);
+                byte[] secondValue = second.valueAt(i);
+                if (!Arrays.equals(firstValue, secondValue)) {
+                    return false;
+                }
+            }
+            return true;
+        }
+    }
+
+    /** An easy-to-use wrapper of {@link Arrays#deepHashCode}. */
+    private static int deepHashCode(Object... values) {
+        return Arrays.deepHashCode(values);
+    }
+
+    /**
+     * Converts this {@link ActiveOperationalDataset} object to a series of Thread TLVs.
+     *
+     * <p>See the <a href="https://www.threadgroup.org/support#specifications">Thread
+     * specification</a> for the definition of the Thread TLV format.
+     *
+     * @return a series of Thread TLVs which contain this Active Operational Dataset
+     */
+    @NonNull
+    public byte[] toThreadTlvs() {
+        ByteArrayOutputStream dataset = new ByteArrayOutputStream();
+
+        dataset.write(TYPE_ACTIVE_TIMESTAMP);
+        byte[] activeTimestampBytes = mActiveTimestamp.toTlvValue();
+        dataset.write(activeTimestampBytes.length);
+        dataset.write(activeTimestampBytes, 0, activeTimestampBytes.length);
+
+        dataset.write(TYPE_NETWORK_NAME);
+        byte[] networkNameBytes = mNetworkName.getBytes(UTF_8);
+        dataset.write(networkNameBytes.length);
+        dataset.write(networkNameBytes, 0, networkNameBytes.length);
+
+        dataset.write(TYPE_EXTENDED_PAN_ID);
+        dataset.write(mExtendedPanId.length);
+        dataset.write(mExtendedPanId, 0, mExtendedPanId.length);
+
+        dataset.write(TYPE_PAN_ID);
+        dataset.write(LENGTH_PAN_ID);
+        dataset.write(mPanId >> 8);
+        dataset.write(mPanId);
+
+        dataset.write(TYPE_CHANNEL);
+        dataset.write(LENGTH_CHANNEL);
+        dataset.write(mChannelPage);
+        dataset.write(mChannel >> 8);
+        dataset.write(mChannel);
+
+        encodeChannelMask(mChannelMask, dataset);
+
+        dataset.write(TYPE_PSKC);
+        dataset.write(mPskc.length);
+        dataset.write(mPskc, 0, mPskc.length);
+
+        dataset.write(TYPE_NETWORK_KEY);
+        dataset.write(mNetworkKey.length);
+        dataset.write(mNetworkKey, 0, mNetworkKey.length);
+
+        dataset.write(TYPE_MESH_LOCAL_PREFIX);
+        dataset.write(mMeshLocalPrefix.getPrefixLength() / 8);
+        dataset.write(mMeshLocalPrefix.getRawAddress(), 0, mMeshLocalPrefix.getPrefixLength() / 8);
+
+        dataset.write(TYPE_SECURITY_POLICY);
+        byte[] securityPolicyBytes = mSecurityPolicy.toTlvValue();
+        dataset.write(securityPolicyBytes.length);
+        dataset.write(securityPolicyBytes, 0, securityPolicyBytes.length);
+
+        for (int i = 0; i < mUnknownTlvs.size(); i++) {
+            byte[] value = mUnknownTlvs.valueAt(i);
+            dataset.write(mUnknownTlvs.keyAt(i));
+            dataset.write(value.length);
+            dataset.write(value, 0, value.length);
+        }
+
+        return dataset.toByteArray();
+    }
+
+    /** Returns the Active Timestamp. */
+    @NonNull
+    public OperationalDatasetTimestamp getActiveTimestamp() {
+        return mActiveTimestamp;
+    }
+
+    /** Returns the Network Name. */
+    @NonNull
+    @Size(min = LENGTH_MIN_NETWORK_NAME_BYTES, max = LENGTH_MAX_NETWORK_NAME_BYTES)
+    public String getNetworkName() {
+        return mNetworkName;
+    }
+
+    /** Returns the Extended PAN ID. */
+    @NonNull
+    @Size(LENGTH_EXTENDED_PAN_ID)
+    public byte[] getExtendedPanId() {
+        return mExtendedPanId.clone();
+    }
+
+    /** Returns the PAN ID. */
+    @IntRange(from = 0, to = 0xfffe)
+    public int getPanId() {
+        return mPanId;
+    }
+
+    /** Returns the Channel. */
+    @IntRange(from = 0, to = 65535)
+    public int getChannel() {
+        return mChannel;
+    }
+
+    /** Returns the Channel Page. */
+    @IntRange(from = 0, to = 255)
+    public int getChannelPage() {
+        return mChannelPage;
+    }
+
+    /**
+     * Returns the Channel masks. For the returned {@link SparseArray}, the key is the Channel Page
+     * and the value is the Channel Mask.
+     */
+    @NonNull
+    @Size(min = 1)
+    public SparseArray<byte[]> getChannelMask() {
+        return deepCloneSparseArray(mChannelMask);
+    }
+
+    private static SparseArray<byte[]> deepCloneSparseArray(SparseArray<byte[]> src) {
+        SparseArray<byte[]> dst = new SparseArray<>(src.size());
+        for (int i = 0; i < src.size(); i++) {
+            dst.put(src.keyAt(i), src.valueAt(i).clone());
+        }
+        return dst;
+    }
+
+    /** Returns the PSKc. */
+    @NonNull
+    @Size(LENGTH_PSKC)
+    public byte[] getPskc() {
+        return mPskc.clone();
+    }
+
+    /** Returns the Network Key. */
+    @NonNull
+    @Size(LENGTH_NETWORK_KEY)
+    public byte[] getNetworkKey() {
+        return mNetworkKey.clone();
+    }
+
+    /**
+     * Returns the Mesh-local Prefix. The length of the returned prefix is always {@link
+     * #LENGTH_MESH_LOCAL_PREFIX_BITS}.
+     */
+    @NonNull
+    public IpPrefix getMeshLocalPrefix() {
+        return mMeshLocalPrefix;
+    }
+
+    /** Returns the Security Policy. */
+    @NonNull
+    public SecurityPolicy getSecurityPolicy() {
+        return mSecurityPolicy;
+    }
+
+    /**
+     * Returns Thread TLVs which are not recognized by this device. The returned {@link SparseArray}
+     * associates TLV values to their keys.
+     *
+     * @hide
+     */
+    @NonNull
+    public SparseArray<byte[]> getUnknownTlvs() {
+        return deepCloneSparseArray(mUnknownTlvs);
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @Override
+    public void writeToParcel(@NonNull Parcel dest, int flags) {
+        dest.writeByteArray(toThreadTlvs());
+    }
+
+    @Override
+    public boolean equals(Object other) {
+        if (other == this) {
+            return true;
+        } else if (!(other instanceof ActiveOperationalDataset)) {
+            return false;
+        } else {
+            ActiveOperationalDataset otherDataset = (ActiveOperationalDataset) other;
+            return mActiveTimestamp.equals(otherDataset.mActiveTimestamp)
+                    && mNetworkName.equals(otherDataset.mNetworkName)
+                    && Arrays.equals(mExtendedPanId, otherDataset.mExtendedPanId)
+                    && mPanId == otherDataset.mPanId
+                    && mChannelPage == otherDataset.mChannelPage
+                    && mChannel == otherDataset.mChannel
+                    && areByteSparseArraysEqual(mChannelMask, otherDataset.mChannelMask)
+                    && Arrays.equals(mPskc, otherDataset.mPskc)
+                    && Arrays.equals(mNetworkKey, otherDataset.mNetworkKey)
+                    && mMeshLocalPrefix.equals(otherDataset.mMeshLocalPrefix)
+                    && mSecurityPolicy.equals(otherDataset.mSecurityPolicy)
+                    && areByteSparseArraysEqual(mUnknownTlvs, otherDataset.mUnknownTlvs);
+        }
+    }
+
+    @Override
+    public int hashCode() {
+        return deepHashCode(
+                mActiveTimestamp,
+                mNetworkName,
+                mExtendedPanId,
+                mPanId,
+                mChannel,
+                mChannelPage,
+                mChannelMask,
+                mPskc,
+                mNetworkKey,
+                mMeshLocalPrefix,
+                mSecurityPolicy);
+    }
+
+    @Override
+    public String toString() {
+        StringBuilder sb = new StringBuilder();
+        sb.append("{networkName=")
+                .append(getNetworkName())
+                .append(", extendedPanId=")
+                .append(dumpHexString(getExtendedPanId()))
+                .append(", panId=")
+                .append(getPanId())
+                .append(", channel=")
+                .append(getChannel())
+                .append(", activeTimestamp=")
+                .append(getActiveTimestamp())
+                .append("}");
+        return sb.toString();
+    }
+
+    /** The builder for creating {@link ActiveOperationalDataset} objects. */
+    public static final class Builder {
+        private OperationalDatasetTimestamp mActiveTimestamp;
+        private String mNetworkName;
+        private byte[] mExtendedPanId;
+        private Integer mPanId;
+        private Integer mChannel;
+        private Integer mChannelPage;
+        private SparseArray<byte[]> mChannelMask;
+        private byte[] mPskc;
+        private byte[] mNetworkKey;
+        private IpPrefix mMeshLocalPrefix;
+        private SecurityPolicy mSecurityPolicy;
+        private SparseArray<byte[]> mUnknownTlvs;
+
+        /**
+         * Creates a {@link Builder} object with values from an {@link ActiveOperationalDataset}
+         * object.
+         */
+        public Builder(@NonNull ActiveOperationalDataset activeOpDataset) {
+            requireNonNull(activeOpDataset, "activeOpDataset cannot be null");
+
+            this.mActiveTimestamp = activeOpDataset.mActiveTimestamp;
+            this.mNetworkName = activeOpDataset.mNetworkName;
+            this.mExtendedPanId = activeOpDataset.mExtendedPanId.clone();
+            this.mPanId = activeOpDataset.mPanId;
+            this.mChannel = activeOpDataset.mChannel;
+            this.mChannelPage = activeOpDataset.mChannelPage;
+            this.mChannelMask = deepCloneSparseArray(activeOpDataset.mChannelMask);
+            this.mPskc = activeOpDataset.mPskc.clone();
+            this.mNetworkKey = activeOpDataset.mNetworkKey.clone();
+            this.mMeshLocalPrefix = activeOpDataset.mMeshLocalPrefix;
+            this.mSecurityPolicy = activeOpDataset.mSecurityPolicy;
+            this.mUnknownTlvs = deepCloneSparseArray(activeOpDataset.mUnknownTlvs);
+        }
+
+        /**
+         * Creates an empty {@link Builder} object.
+         *
+         * <p>An empty builder cannot build a new {@link ActiveOperationalDataset} object. The
+         * Active Operational Dataset parameters must be set with setters of this builder.
+         */
+        public Builder() {
+            mChannelMask = new SparseArray<>();
+            mUnknownTlvs = new SparseArray<>();
+        }
+
+        /**
+         * Sets the Active Timestamp.
+         *
+         * @param activeTimestamp Active Timestamp of the Operational Dataset
+         */
+        @NonNull
+        public Builder setActiveTimestamp(@NonNull OperationalDatasetTimestamp activeTimestamp) {
+            requireNonNull(activeTimestamp, "activeTimestamp cannot be null");
+            this.mActiveTimestamp = activeTimestamp;
+            return this;
+        }
+
+        /**
+         * Sets the Network Name.
+         *
+         * @param networkName the name of the Thread network
+         * @throws IllegalArgumentException if length of the UTF-8 representation of {@code
+         *     networkName} isn't in range of [{@link #LENGTH_MIN_NETWORK_NAME_BYTES}, {@link
+         *     #LENGTH_MAX_NETWORK_NAME_BYTES}].
+         */
+        @NonNull
+        public Builder setNetworkName(
+                @NonNull
+                        @Size(
+                                min = LENGTH_MIN_NETWORK_NAME_BYTES,
+                                max = LENGTH_MAX_NETWORK_NAME_BYTES)
+                        String networkName) {
+            requireNonNull(networkName, "networkName cannot be null");
+
+            int nameLength = networkName.getBytes(UTF_8).length;
+            checkArgument(
+                    nameLength >= LENGTH_MIN_NETWORK_NAME_BYTES
+                            && nameLength <= LENGTH_MAX_NETWORK_NAME_BYTES,
+                    "Invalid network name (length = %d, expectedLengthRange = [%d, %d])",
+                    nameLength,
+                    LENGTH_MIN_NETWORK_NAME_BYTES,
+                    LENGTH_MAX_NETWORK_NAME_BYTES);
+            this.mNetworkName = networkName;
+            return this;
+        }
+
+        /**
+         * Sets the Extended PAN ID.
+         *
+         * <p>Use with caution. A randomly generated Extended PAN ID should be used for real Thread
+         * networks. It's discouraged to call this method to override the default value created by
+         * {@link ActiveOperationalDataset#createRandomDataset} in production.
+         *
+         * @throws IllegalArgumentException if length of {@code extendedPanId} is not {@link
+         *     #LENGTH_EXTENDED_PAN_ID}.
+         */
+        @NonNull
+        public Builder setExtendedPanId(
+                @NonNull @Size(LENGTH_EXTENDED_PAN_ID) byte[] extendedPanId) {
+            requireNonNull(extendedPanId, "extendedPanId cannot be null");
+            checkArgument(
+                    extendedPanId.length == LENGTH_EXTENDED_PAN_ID,
+                    "Invalid extended PAN ID (length = %d, expectedLength = %d)",
+                    extendedPanId.length,
+                    LENGTH_EXTENDED_PAN_ID);
+            this.mExtendedPanId = extendedPanId.clone();
+            return this;
+        }
+
+        /**
+         * Sets the PAN ID.
+         *
+         * @throws IllegalArgumentException if {@code panId} is not in range of 0x0-0xfffe
+         */
+        @NonNull
+        public Builder setPanId(@IntRange(from = 0, to = 0xfffe) int panId) {
+            checkArgument(
+                    panId >= 0 && panId <= 0xfffe,
+                    "PAN ID exceeds allowed range (panid = %d, allowedRange = [0x0, 0xffff])",
+                    panId);
+            this.mPanId = panId;
+            return this;
+        }
+
+        /**
+         * Sets the Channel Page and Channel.
+         *
+         * <p>Channel Pages other than {@link #CHANNEL_PAGE_24_GHZ} are undefined and may lead to
+         * unexpected behavior if it's applied to Thread devices.
+         *
+         * @throws IllegalArgumentException if invalid channel is specified for the {@code
+         *     channelPage}
+         */
+        @NonNull
+        public Builder setChannel(
+                @IntRange(from = 0, to = 255) int page,
+                @IntRange(from = 0, to = 65535) int channel) {
+            checkArgument(
+                    page >= 0 && page <= 255,
+                    "Invalid channel page (page = %d, allowedRange = [0, 255])",
+                    page);
+            if (page == CHANNEL_PAGE_24_GHZ) {
+                checkArgument(
+                        channel >= CHANNEL_MIN_24_GHZ && channel <= CHANNEL_MAX_24_GHZ,
+                        "Invalid channel %d in page %d (allowedChannelRange = [%d, %d])",
+                        channel,
+                        page,
+                        CHANNEL_MIN_24_GHZ,
+                        CHANNEL_MAX_24_GHZ);
+            } else {
+                checkArgument(
+                        channel >= 0 && channel <= 65535,
+                        "Invalid channel %d in page %d "
+                                + "(channel = %d, allowedChannelRange = [0, 65535])",
+                        channel,
+                        page,
+                        channel);
+            }
+
+            this.mChannelPage = page;
+            this.mChannel = channel;
+            return this;
+        }
+
+        /**
+         * Sets the Channel Mask.
+         *
+         * @throws IllegalArgumentException if {@code channelMask} is empty
+         */
+        @NonNull
+        public Builder setChannelMask(@NonNull @Size(min = 1) SparseArray<byte[]> channelMask) {
+            requireNonNull(channelMask, "channelMask cannot be null");
+            checkArgument(channelMask.size() > 0, "channelMask is empty");
+            this.mChannelMask = deepCloneSparseArray(channelMask);
+            return this;
+        }
+
+        /**
+         * Sets the PSKc.
+         *
+         * <p>Use with caution. A randomly generated PSKc should be used for real Thread networks.
+         * It's discouraged to call this method to override the default value created by {@link
+         * ActiveOperationalDataset#createRandomDataset} in production.
+         *
+         * @param pskc the key stretched version of the Commissioning Credential for the network
+         * @throws IllegalArgumentException if length of {@code pskc} is not {@link #LENGTH_PSKC}
+         */
+        @NonNull
+        public Builder setPskc(@NonNull @Size(LENGTH_PSKC) byte[] pskc) {
+            requireNonNull(pskc, "pskc cannot be null");
+            checkArgument(
+                    pskc.length == LENGTH_PSKC,
+                    "Invalid PSKc length (length = %d, expectedLength = %d)",
+                    pskc.length,
+                    LENGTH_PSKC);
+            this.mPskc = pskc.clone();
+            return this;
+        }
+
+        /**
+         * Sets the Network Key.
+         *
+         * <p>Use with caution, randomly generated Network Key should be used for real Thread
+         * networks. It's discouraged to call this method to override the default value created by
+         * {@link ActiveOperationalDataset#createRandomDataset} in production.
+         *
+         * @param networkKey a 128-bit security key-derivation key for the Thread Network
+         * @throws IllegalArgumentException if length of {@code networkKey} is not {@link
+         *     #LENGTH_NETWORK_KEY}
+         */
+        @NonNull
+        public Builder setNetworkKey(@NonNull @Size(LENGTH_NETWORK_KEY) byte[] networkKey) {
+            requireNonNull(networkKey, "networkKey cannot be null");
+            checkArgument(
+                    networkKey.length == LENGTH_NETWORK_KEY,
+                    "Invalid network key length (length = %d, expectedLength = %d)",
+                    networkKey.length,
+                    LENGTH_NETWORK_KEY);
+            this.mNetworkKey = networkKey.clone();
+            return this;
+        }
+
+        /**
+         * Sets the Mesh-Local Prefix.
+         *
+         * @param meshLocalPrefix the prefix used for realm-local traffic within the mesh
+         * @throws IllegalArgumentException if prefix length of {@code meshLocalPrefix} isn't {@link
+         *     #LENGTH_MESH_LOCAL_PREFIX_BITS} or {@code meshLocalPrefix} doesn't start with {@code
+         *     0xfd}
+         */
+        @NonNull
+        public Builder setMeshLocalPrefix(@NonNull IpPrefix meshLocalPrefix) {
+            requireNonNull(meshLocalPrefix, "meshLocalPrefix cannot be null");
+            checkArgument(
+                    meshLocalPrefix.getPrefixLength() == LENGTH_MESH_LOCAL_PREFIX_BITS,
+                    "Invalid mesh-local prefix length (length = %d, expectedLength = %d)",
+                    meshLocalPrefix.getPrefixLength(),
+                    LENGTH_MESH_LOCAL_PREFIX_BITS);
+            checkArgument(
+                    meshLocalPrefix.getRawAddress()[0] == MESH_LOCAL_PREFIX_FIRST_BYTE,
+                    "Mesh-local prefix must start with 0xfd: " + meshLocalPrefix);
+            this.mMeshLocalPrefix = meshLocalPrefix;
+            return this;
+        }
+
+        @NonNull
+        private Builder setMeshLocalPrefix(byte[] meshLocalPrefix) {
+            final int prefixLength = meshLocalPrefix.length * 8;
+            checkArgument(
+                    prefixLength == LENGTH_MESH_LOCAL_PREFIX_BITS,
+                    "Invalid mesh-local prefix length (length = %d, expectedLength = %d)",
+                    prefixLength,
+                    LENGTH_MESH_LOCAL_PREFIX_BITS);
+            byte[] ip6RawAddress = new byte[16];
+            System.arraycopy(meshLocalPrefix, 0, ip6RawAddress, 0, meshLocalPrefix.length);
+            try {
+                return setMeshLocalPrefix(
+                        new IpPrefix(Inet6Address.getByAddress(ip6RawAddress), prefixLength));
+            } catch (UnknownHostException e) {
+                // Can't happen because numeric address is provided
+                throw new AssertionError(e);
+            }
+        }
+
+        /** Sets the Security Policy. */
+        @NonNull
+        public Builder setSecurityPolicy(@NonNull SecurityPolicy securityPolicy) {
+            requireNonNull(securityPolicy, "securityPolicy cannot be null");
+            this.mSecurityPolicy = securityPolicy;
+            return this;
+        }
+
+        /**
+         * Sets additional unknown TLVs.
+         *
+         * @hide
+         */
+        @NonNull
+        public Builder setUnknownTlvs(@NonNull SparseArray<byte[]> unknownTlvs) {
+            requireNonNull(unknownTlvs, "unknownTlvs cannot be null");
+            mUnknownTlvs = deepCloneSparseArray(unknownTlvs);
+            return this;
+        }
+
+        /** Adds one more unknown TLV. @hide */
+        @VisibleForTesting
+        @NonNull
+        public Builder addUnknownTlv(int type, byte[] value) {
+            mUnknownTlvs.put(type, value);
+            return this;
+        }
+
+        /**
+         * Creates a new {@link ActiveOperationalDataset} object.
+         *
+         * @throws IllegalStateException if any of the fields isn't set or the total length exceeds
+         *     {@link #LENGTH_MAX_DATASET_TLVS} bytes
+         */
+        @NonNull
+        public ActiveOperationalDataset build() {
+            checkState(mActiveTimestamp != null, "Active Timestamp is missing");
+            checkState(mNetworkName != null, "Network Name is missing");
+            checkState(mExtendedPanId != null, "Extended PAN ID is missing");
+            checkState(mPanId != null, "PAN ID is missing");
+            checkState(mChannel != null, "Channel is missing");
+            checkState(mChannelPage != null, "Channel Page is missing");
+            checkState(mChannelMask.size() != 0, "Channel Mask is missing");
+            checkState(mPskc != null, "PSKc is missing");
+            checkState(mNetworkKey != null, "Network Key is missing");
+            checkState(mMeshLocalPrefix != null, "Mesh Local Prefix is missing");
+            checkState(mSecurityPolicy != null, "Security Policy is missing");
+
+            int length = getTotalDatasetLength();
+            if (length > LENGTH_MAX_DATASET_TLVS) {
+                throw new IllegalStateException(
+                        String.format(
+                                "Total dataset length exceeds max length %d (actual is %d)",
+                                LENGTH_MAX_DATASET_TLVS, length));
+            }
+
+            return new ActiveOperationalDataset(this);
+        }
+
+        private int getTotalDatasetLength() {
+            int length =
+                    2 * 9 // 9 fields with 1 byte of type and 1 byte of length
+                            + OperationalDatasetTimestamp.LENGTH_TIMESTAMP
+                            + mNetworkName.getBytes(UTF_8).length
+                            + LENGTH_EXTENDED_PAN_ID
+                            + LENGTH_PAN_ID
+                            + LENGTH_CHANNEL
+                            + LENGTH_PSKC
+                            + LENGTH_NETWORK_KEY
+                            + LENGTH_MESH_LOCAL_PREFIX_BITS / 8
+                            + mSecurityPolicy.toTlvValue().length;
+
+            for (int i = 0; i < mChannelMask.size(); i++) {
+                length += 2 + mChannelMask.valueAt(i).length;
+            }
+
+            // For the type and length bytes of the Channel Mask TLV because the masks are encoded
+            // as TLVs in TLV.
+            length += 2;
+
+            for (int i = 0; i < mUnknownTlvs.size(); i++) {
+                length += 2 + mUnknownTlvs.valueAt(i).length;
+            }
+
+            return length;
+        }
+    }
+
+    /**
+     * The Security Policy of Thread Operational Dataset which provides an administrator with a way
+     * to enable or disable certain security related behaviors.
+     */
+    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;
+
+        private final int mRotationTimeHours;
+        private final byte[] mFlags;
+
+        /**
+         * Creates a new {@link SecurityPolicy} object.
+         *
+         * @param rotationTimeHours the value for Thread key rotation in hours. Must be in range of
+         *     0x1-0xffff.
+         * @param flags security policy flags with length of either 1 byte for Thread 1.1 or 2 bytes
+         *     for Thread 1.2 or higher.
+         * @throws IllegalArgumentException if {@code rotationTimeHours} is not in range of
+         *     0x1-0xffff or length of {@code flags} is smaller than {@link
+         *     #LENGTH_MIN_SECURITY_POLICY_FLAGS}.
+         */
+        public SecurityPolicy(
+                @IntRange(from = 0x1, to = 0xffff) int rotationTimeHours,
+                @NonNull @Size(min = LENGTH_MIN_SECURITY_POLICY_FLAGS) byte[] flags) {
+            requireNonNull(flags, "flags cannot be null");
+            checkArgument(
+                    rotationTimeHours >= 1 && rotationTimeHours <= 0xffff,
+                    "Rotation time exceeds allowed range (rotationTimeHours = %d, allowedRange ="
+                            + " [0x1, 0xffff])",
+                    rotationTimeHours);
+            checkArgument(
+                    flags.length >= LENGTH_MIN_SECURITY_POLICY_FLAGS,
+                    "Invalid security policy flags length (length = %d, minimumLength = %d)",
+                    flags.length,
+                    LENGTH_MIN_SECURITY_POLICY_FLAGS);
+            this.mRotationTimeHours = rotationTimeHours;
+            this.mFlags = flags.clone();
+        }
+
+        /**
+         * Creates a new {@link SecurityPolicy} object from the Security Policy TLV value.
+         *
+         * @hide
+         */
+        @VisibleForTesting
+        @NonNull
+        public static SecurityPolicy fromTlvValue(byte[] encodedSecurityPolicy) {
+            checkArgument(
+                    encodedSecurityPolicy.length
+                            >= LENGTH_SECURITY_POLICY_ROTATION_TIME
+                                    + LENGTH_MIN_SECURITY_POLICY_FLAGS,
+                    "Invalid Security Policy TLV length (length = %d, minimumLength = %d)",
+                    encodedSecurityPolicy.length,
+                    LENGTH_SECURITY_POLICY_ROTATION_TIME + LENGTH_MIN_SECURITY_POLICY_FLAGS);
+
+            return new SecurityPolicy(
+                    ((encodedSecurityPolicy[0] & 0xff) << 8) | (encodedSecurityPolicy[1] & 0xff),
+                    Arrays.copyOfRange(
+                            encodedSecurityPolicy,
+                            LENGTH_SECURITY_POLICY_ROTATION_TIME,
+                            encodedSecurityPolicy.length));
+        }
+
+        /**
+         * Converts this {@link SecurityPolicy} object to Security Policy TLV value.
+         *
+         * @hide
+         */
+        @VisibleForTesting
+        @NonNull
+        public byte[] toTlvValue() {
+            ByteArrayOutputStream result = new ByteArrayOutputStream();
+            result.write(mRotationTimeHours >> 8);
+            result.write(mRotationTimeHours);
+            result.write(mFlags, 0, mFlags.length);
+            return result.toByteArray();
+        }
+
+        /** Returns the Security Policy Rotation Time in hours. */
+        @IntRange(from = 0x1, to = 0xffff)
+        public int getRotationTimeHours() {
+            return mRotationTimeHours;
+        }
+
+        /** Returns 1 byte flags for Thread 1.1 or 2 bytes flags for Thread 1.2. */
+        @NonNull
+        @Size(min = LENGTH_MIN_SECURITY_POLICY_FLAGS)
+        public byte[] getFlags() {
+            return mFlags.clone();
+        }
+
+        @Override
+        public boolean equals(@Nullable Object other) {
+            if (this == other) {
+                return true;
+            } else if (!(other instanceof SecurityPolicy)) {
+                return false;
+            } else {
+                SecurityPolicy otherSecurityPolicy = (SecurityPolicy) other;
+                return mRotationTimeHours == otherSecurityPolicy.mRotationTimeHours
+                        && Arrays.equals(mFlags, otherSecurityPolicy.mFlags);
+            }
+        }
+
+        @Override
+        public int hashCode() {
+            return deepHashCode(mRotationTimeHours, mFlags);
+        }
+
+        @Override
+        public String toString() {
+            StringBuilder sb = new StringBuilder();
+            sb.append("{rotation=")
+                    .append(mRotationTimeHours)
+                    .append(", flags=")
+                    .append(dumpHexString(mFlags))
+                    .append("}");
+            return sb.toString();
+        }
+    }
+}
diff --git a/thread/framework/java/android/net/thread/OperationalDatasetTimestamp.java b/thread/framework/java/android/net/thread/OperationalDatasetTimestamp.java
new file mode 100644
index 0000000..bda9373
--- /dev/null
+++ b/thread/framework/java/android/net/thread/OperationalDatasetTimestamp.java
@@ -0,0 +1,220 @@
+/*
+ * 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.thread;
+
+import static com.android.internal.util.Preconditions.checkArgument;
+
+import static java.util.Objects.requireNonNull;
+
+import android.annotation.FlaggedApi;
+import android.annotation.IntRange;
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.annotation.SystemApi;
+
+import java.nio.ByteBuffer;
+import java.time.Instant;
+import java.util.Objects;
+
+/**
+ * The timestamp of Thread Operational Dataset.
+ *
+ * @see ActiveOperationalDataset
+ * @see PendingOperationalDataset
+ * @hide
+ */
+@FlaggedApi(ThreadNetworkFlags.FLAG_THREAD_ENABLED)
+@SystemApi
+public final class OperationalDatasetTimestamp {
+    /** @hide */
+    public static final int LENGTH_TIMESTAMP = Long.BYTES;
+
+    private static final long TICKS_UPPER_BOUND = 0x8000;
+
+    private final Instant mInstant;
+    private final boolean mIsAuthoritativeSource;
+
+    /**
+     * Creates a new {@link OperationalDatasetTimestamp} object from an {@link Instant}.
+     *
+     * <p>The {@code seconds} is set to {@code instant.getEpochSecond()}, {@code ticks} is set to
+     * {@link instant#getNano()} based on frequency of 32768 Hz, and {@code isAuthoritativeSource}
+     * is set to {@code true}.
+     *
+     * @throws IllegalArgumentException if {@code instant.getEpochSecond()} is larger than {@code
+     *     0xffffffffffffL}
+     */
+    @NonNull
+    public static OperationalDatasetTimestamp fromInstant(@NonNull Instant instant) {
+        return new OperationalDatasetTimestamp(instant, /* isAuthoritativeSource= */ true);
+    }
+
+    /** Converts this {@link OperationalDatasetTimestamp} object to an {@link Instant}. */
+    @NonNull
+    public Instant toInstant() {
+        return mInstant;
+    }
+
+    /**
+     * Creates a new {@link OperationalDatasetTimestamp} object from the OperationalDatasetTimestamp
+     * TLV value.
+     *
+     * @hide
+     */
+    @NonNull
+    public static OperationalDatasetTimestamp fromTlvValue(@NonNull byte[] encodedTimestamp) {
+        requireNonNull(encodedTimestamp, "encodedTimestamp cannot be null");
+        checkArgument(
+                encodedTimestamp.length == LENGTH_TIMESTAMP,
+                "Invalid Thread OperationalDatasetTimestamp length (length = %d,"
+                        + " expectedLength=%d)",
+                encodedTimestamp.length,
+                LENGTH_TIMESTAMP);
+        long longTimestamp = ByteBuffer.wrap(encodedTimestamp).getLong();
+        return new OperationalDatasetTimestamp(
+                (longTimestamp >> 16) & 0x0000ffffffffffffL,
+                (int) ((longTimestamp >> 1) & 0x7fffL),
+                (longTimestamp & 0x01) != 0);
+    }
+
+    /**
+     * Converts this {@link OperationalDatasetTimestamp} object to Thread TLV value.
+     *
+     * @hide
+     */
+    @NonNull
+    public byte[] toTlvValue() {
+        byte[] tlv = new byte[LENGTH_TIMESTAMP];
+        ByteBuffer buffer = ByteBuffer.wrap(tlv);
+        long encodedValue =
+                (mInstant.getEpochSecond() << 16)
+                        | ((mInstant.getNano() * TICKS_UPPER_BOUND / 1000000000L) << 1)
+                        | (mIsAuthoritativeSource ? 1 : 0);
+        buffer.putLong(encodedValue);
+        return tlv;
+    }
+
+    /**
+     * Creates a new {@link OperationalDatasetTimestamp} object.
+     *
+     * @param seconds the value encodes a Unix Time value. Must be in the range of
+     *     0x0-0xffffffffffffL
+     * @param ticks the value encodes the fractional Unix Time value in 32.768 kHz resolution. Must
+     *     be in the range of 0x0-0x7fff
+     * @param isAuthoritativeSource the flag indicates the time was obtained from an authoritative
+     *     source: either NTP (Network Time Protocol), GPS (Global Positioning System), cell
+     *     network, or other method
+     * @throws IllegalArgumentException if the {@code seconds} is not in range of
+     *     0x0-0xffffffffffffL or {@code ticks} is not in range of 0x0-0x7fff
+     */
+    public OperationalDatasetTimestamp(
+            @IntRange(from = 0x0, to = 0xffffffffffffL) long seconds,
+            @IntRange(from = 0x0, to = 0x7fff) int ticks,
+            boolean isAuthoritativeSource) {
+        this(makeInstant(seconds, ticks), isAuthoritativeSource);
+    }
+
+    private static Instant makeInstant(long seconds, int ticks) {
+        checkArgument(
+                seconds >= 0 && seconds <= 0xffffffffffffL,
+                "seconds exceeds allowed range (seconds = %d,"
+                        + " allowedRange = [0x0, 0xffffffffffffL])",
+                seconds);
+        checkArgument(
+                ticks >= 0 && ticks <= 0x7fff,
+                "ticks exceeds allowed ranged (ticks = %d, allowedRange" + " = [0x0, 0x7fff])",
+                ticks);
+        long nanos = Math.round((double) ticks * 1000000000L / TICKS_UPPER_BOUND);
+        return Instant.ofEpochSecond(seconds, nanos);
+    }
+
+    /**
+     * Creates new {@link OperationalDatasetTimestamp} object.
+     *
+     * @throws IllegalArgumentException if {@code instant.getEpochSecond()} is larger than {@code
+     *     0xffffffffffffL}
+     */
+    private OperationalDatasetTimestamp(@NonNull Instant instant, boolean isAuthoritativeSource) {
+        requireNonNull(instant, "instant cannot be null");
+        long seconds = instant.getEpochSecond();
+        checkArgument(
+                seconds >= 0 && seconds <= 0xffffffffffffL,
+                "instant seconds exceeds allowed range (seconds = %d, allowedRange = [0x0,"
+                        + " 0xffffffffffffL])",
+                seconds);
+        mInstant = instant;
+        mIsAuthoritativeSource = isAuthoritativeSource;
+    }
+
+    /**
+     * Returns the rounded ticks converted from the nano seconds.
+     *
+     * <p>Note that rhe return value can be as large as {@code TICKS_UPPER_BOUND}.
+     */
+    private static int getRoundedTicks(long nanos) {
+        return (int) Math.round((double) nanos * TICKS_UPPER_BOUND / 1000000000L);
+    }
+
+    /** Returns the seconds portion of the timestamp. */
+    public @IntRange(from = 0x0, to = 0xffffffffffffL) long getSeconds() {
+        return mInstant.getEpochSecond() + getRoundedTicks(mInstant.getNano()) / TICKS_UPPER_BOUND;
+    }
+
+    /** Returns the ticks portion of the timestamp. */
+    public @IntRange(from = 0x0, to = 0x7fff) int getTicks() {
+        // the rounded ticks can be 0x8000 if mInstant.getNano() >= 999984742
+        return (int) (getRoundedTicks(mInstant.getNano()) % TICKS_UPPER_BOUND);
+    }
+
+    /** Returns {@code true} if the timestamp comes from an authoritative source. */
+    public boolean isAuthoritativeSource() {
+        return mIsAuthoritativeSource;
+    }
+
+    @Override
+    public String toString() {
+        StringBuilder sb = new StringBuilder();
+        sb.append("{seconds=")
+                .append(getSeconds())
+                .append(", ticks=")
+                .append(getTicks())
+                .append(", isAuthoritativeSource=")
+                .append(isAuthoritativeSource())
+                .append(", instant=")
+                .append(toInstant())
+                .append("}");
+        return sb.toString();
+    }
+
+    @Override
+    public boolean equals(@Nullable Object other) {
+        if (this == other) {
+            return true;
+        } else if (!(other instanceof OperationalDatasetTimestamp)) {
+            return false;
+        } else {
+            OperationalDatasetTimestamp otherTimestamp = (OperationalDatasetTimestamp) other;
+            return mInstant.equals(otherTimestamp.mInstant)
+                    && mIsAuthoritativeSource == otherTimestamp.mIsAuthoritativeSource;
+        }
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(mInstant, mIsAuthoritativeSource);
+    }
+}
diff --git a/thread/framework/java/android/net/thread/PendingOperationalDataset.aidl b/thread/framework/java/android/net/thread/PendingOperationalDataset.aidl
new file mode 100644
index 0000000..e5bc05e
--- /dev/null
+++ b/thread/framework/java/android/net/thread/PendingOperationalDataset.aidl
@@ -0,0 +1,19 @@
+/*
+ * Copyright 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.thread;
+
+parcelable PendingOperationalDataset;
diff --git a/thread/framework/java/android/net/thread/PendingOperationalDataset.java b/thread/framework/java/android/net/thread/PendingOperationalDataset.java
new file mode 100644
index 0000000..4762d7f
--- /dev/null
+++ b/thread/framework/java/android/net/thread/PendingOperationalDataset.java
@@ -0,0 +1,226 @@
+/*
+ * 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.thread;
+
+import static com.android.internal.util.Preconditions.checkArgument;
+
+import static java.util.Objects.requireNonNull;
+
+import android.annotation.FlaggedApi;
+import android.annotation.NonNull;
+import android.annotation.SystemApi;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.util.SparseArray;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.ByteBuffer;
+import java.time.Duration;
+import java.util.Objects;
+
+/**
+ * Data interface for managing a Thread Pending Operational Dataset.
+ *
+ * <p>The Pending Operational Dataset represents an Operational Dataset which will become Active in
+ * a given delay. This is typically used to deploy new network parameters (e.g. Network Key or
+ * Channel) to all devices in the network.
+ *
+ * @hide
+ */
+@FlaggedApi(ThreadNetworkFlags.FLAG_THREAD_ENABLED)
+@SystemApi
+public final class PendingOperationalDataset implements Parcelable {
+    // Value defined in Thread spec 8.10.1.16
+    private static final int TYPE_PENDING_TIMESTAMP = 51;
+
+    // Values defined in Thread spec 8.10.1.17
+    private static final int TYPE_DELAY_TIMER = 52;
+    private static final int LENGTH_DELAY_TIMER_BYTES = 4;
+
+    @NonNull
+    public static final Creator<PendingOperationalDataset> CREATOR =
+            new Creator<>() {
+                @Override
+                public PendingOperationalDataset createFromParcel(Parcel in) {
+                    return PendingOperationalDataset.fromThreadTlvs(in.createByteArray());
+                }
+
+                @Override
+                public PendingOperationalDataset[] newArray(int size) {
+                    return new PendingOperationalDataset[size];
+                }
+            };
+
+    @NonNull private final ActiveOperationalDataset mActiveOpDataset;
+    @NonNull private final OperationalDatasetTimestamp mPendingTimestamp;
+    @NonNull private final Duration mDelayTimer;
+
+    /** Creates a new {@link PendingOperationalDataset} object. */
+    public PendingOperationalDataset(
+            @NonNull ActiveOperationalDataset activeOpDataset,
+            @NonNull OperationalDatasetTimestamp pendingTimestamp,
+            @NonNull Duration delayTimer) {
+        requireNonNull(activeOpDataset, "activeOpDataset cannot be null");
+        requireNonNull(pendingTimestamp, "pendingTimestamp cannot be null");
+        requireNonNull(delayTimer, "delayTimer cannot be null");
+        this.mActiveOpDataset = activeOpDataset;
+        this.mPendingTimestamp = pendingTimestamp;
+        this.mDelayTimer = delayTimer;
+    }
+
+    /**
+     * Creates a new {@link PendingOperationalDataset} object from a series of Thread TLVs.
+     *
+     * <p>{@code tlvs} can be obtained from the value of a Thread Pending Operational Dataset TLV
+     * (see the <a href="https://www.threadgroup.org/support#specifications">Thread
+     * specification</a> for the definition) or the return value of {@link #toThreadTlvs}.
+     *
+     * @throws IllegalArgumentException if {@code tlvs} is malformed or contains an invalid Thread
+     *     TLV
+     */
+    @NonNull
+    public static PendingOperationalDataset fromThreadTlvs(@NonNull byte[] tlvs) {
+        requireNonNull(tlvs, "tlvs cannot be null");
+
+        SparseArray<byte[]> newUnknownTlvs = new SparseArray<>();
+        OperationalDatasetTimestamp pendingTimestamp = null;
+        Duration delayTimer = null;
+        ActiveOperationalDataset activeDataset = ActiveOperationalDataset.fromThreadTlvs(tlvs);
+        SparseArray<byte[]> unknownTlvs = activeDataset.getUnknownTlvs();
+        for (int i = 0; i < unknownTlvs.size(); i++) {
+            int key = unknownTlvs.keyAt(i);
+            byte[] value = unknownTlvs.valueAt(i);
+            switch (key) {
+                case TYPE_PENDING_TIMESTAMP:
+                    pendingTimestamp = OperationalDatasetTimestamp.fromTlvValue(value);
+                    break;
+                case TYPE_DELAY_TIMER:
+                    checkArgument(
+                            value.length == LENGTH_DELAY_TIMER_BYTES,
+                            "Invalid delay timer (length = %d, expectedLength = %d)",
+                            value.length,
+                            LENGTH_DELAY_TIMER_BYTES);
+                    int millis = ByteBuffer.wrap(value).getInt();
+                    delayTimer = Duration.ofMillis(Integer.toUnsignedLong(millis));
+                    break;
+                default:
+                    newUnknownTlvs.put(key, value);
+                    break;
+            }
+        }
+
+        if (pendingTimestamp == null) {
+            throw new IllegalArgumentException("Pending Timestamp is missing");
+        }
+        if (delayTimer == null) {
+            throw new IllegalArgumentException("Delay Timer is missing");
+        }
+
+        activeDataset =
+                new ActiveOperationalDataset.Builder(activeDataset)
+                        .setUnknownTlvs(newUnknownTlvs)
+                        .build();
+        return new PendingOperationalDataset(activeDataset, pendingTimestamp, delayTimer);
+    }
+
+    /** Returns the Active Operational Dataset. */
+    @NonNull
+    public ActiveOperationalDataset getActiveOperationalDataset() {
+        return mActiveOpDataset;
+    }
+
+    /** Returns the Pending Timestamp. */
+    @NonNull
+    public OperationalDatasetTimestamp getPendingTimestamp() {
+        return mPendingTimestamp;
+    }
+
+    /** Returns the Delay Timer. */
+    @NonNull
+    public Duration getDelayTimer() {
+        return mDelayTimer;
+    }
+
+    /**
+     * Converts this {@link PendingOperationalDataset} object to a series of Thread TLVs.
+     *
+     * <p>See the <a href="https://www.threadgroup.org/support#specifications">Thread
+     * specification</a> for the definition of the Thread TLV format.
+     */
+    @NonNull
+    public byte[] toThreadTlvs() {
+        ByteArrayOutputStream dataset = new ByteArrayOutputStream();
+
+        byte[] activeDatasetBytes = mActiveOpDataset.toThreadTlvs();
+        dataset.write(activeDatasetBytes, 0, activeDatasetBytes.length);
+
+        dataset.write(TYPE_PENDING_TIMESTAMP);
+        byte[] pendingTimestampBytes = mPendingTimestamp.toTlvValue();
+        dataset.write(pendingTimestampBytes.length);
+        dataset.write(pendingTimestampBytes, 0, pendingTimestampBytes.length);
+
+        dataset.write(TYPE_DELAY_TIMER);
+        byte[] delayTimerBytes = new byte[LENGTH_DELAY_TIMER_BYTES];
+        ByteBuffer.wrap(delayTimerBytes).putInt((int) mDelayTimer.toMillis());
+        dataset.write(delayTimerBytes.length);
+        dataset.write(delayTimerBytes, 0, delayTimerBytes.length);
+
+        return dataset.toByteArray();
+    }
+
+    @Override
+    public boolean equals(Object other) {
+        if (this == other) {
+            return true;
+        } else if (!(other instanceof PendingOperationalDataset)) {
+            return false;
+        } else {
+            PendingOperationalDataset otherDataset = (PendingOperationalDataset) other;
+            return mActiveOpDataset.equals(otherDataset.mActiveOpDataset)
+                    && mPendingTimestamp.equals(otherDataset.mPendingTimestamp)
+                    && mDelayTimer.equals(otherDataset.mDelayTimer);
+        }
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(mActiveOpDataset, mPendingTimestamp, mDelayTimer);
+    }
+
+    @Override
+    public String toString() {
+        StringBuilder sb = new StringBuilder();
+        sb.append("{activeDataset=")
+                .append(getActiveOperationalDataset())
+                .append(", pendingTimestamp=")
+                .append(getPendingTimestamp())
+                .append(", delayTimer=")
+                .append(getDelayTimer())
+                .append("}");
+        return sb.toString();
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @Override
+    public void writeToParcel(@NonNull Parcel dest, int flags) {
+        dest.writeByteArray(toThreadTlvs());
+    }
+}
diff --git a/thread/framework/java/android/net/thread/ThreadNetworkController.java b/thread/framework/java/android/net/thread/ThreadNetworkController.java
index fe189c2..7575757 100644
--- a/thread/framework/java/android/net/thread/ThreadNetworkController.java
+++ b/thread/framework/java/android/net/thread/ThreadNetworkController.java
@@ -18,6 +18,7 @@
 
 import static java.util.Objects.requireNonNull;
 
+import android.annotation.FlaggedApi;
 import android.annotation.IntDef;
 import android.annotation.NonNull;
 import android.annotation.SystemApi;
@@ -30,9 +31,10 @@
  * Provides the primary API for controlling all aspects of a Thread network.
  *
  * @hide
- */
+*/
+@FlaggedApi(ThreadNetworkFlags.FLAG_THREAD_ENABLED)
 @SystemApi
-public class ThreadNetworkController {
+public final class ThreadNetworkController {
 
     /** Thread standard version 1.3. */
     public static final int THREAD_VERSION_1_3 = 4;
diff --git a/thread/framework/java/android/net/thread/ThreadNetworkFlags.java b/thread/framework/java/android/net/thread/ThreadNetworkFlags.java
new file mode 100644
index 0000000..e6ab988
--- /dev/null
+++ b/thread/framework/java/android/net/thread/ThreadNetworkFlags.java
@@ -0,0 +1,31 @@
+/*
+ * 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.thread;
+
+/**
+ * Container for flag constants defined in the "thread_network" namespace.
+ *
+ * @hide
+ */
+// TODO: replace this class with auto-generated "com.android.net.thread.flags.Flags" once the
+// flagging infra is fully supported for mainline modules.
+public final class ThreadNetworkFlags {
+    /** @hide */
+    public static final String FLAG_THREAD_ENABLED = "com.android.net.thread.flags.thread_enabled";
+
+    private ThreadNetworkFlags() {}
+}
diff --git a/thread/framework/java/android/net/thread/ThreadNetworkManager.java b/thread/framework/java/android/net/thread/ThreadNetworkManager.java
index 2a253a1..c3bdbd7 100644
--- a/thread/framework/java/android/net/thread/ThreadNetworkManager.java
+++ b/thread/framework/java/android/net/thread/ThreadNetworkManager.java
@@ -18,6 +18,7 @@
 
 import static java.util.Objects.requireNonNull;
 
+import android.annotation.FlaggedApi;
 import android.annotation.NonNull;
 import android.annotation.SystemApi;
 import android.annotation.SystemService;
@@ -34,9 +35,10 @@
  *
  * @hide
  */
+@FlaggedApi(ThreadNetworkFlags.FLAG_THREAD_ENABLED)
 @SystemApi
 @SystemService(ThreadNetworkManager.SERVICE_NAME)
-public class ThreadNetworkManager {
+public final class ThreadNetworkManager {
     /**
      * This value tracks {@link Context#THREAD_NETWORK_SERVICE}.
      *
diff --git a/thread/tests/cts/Android.bp b/thread/tests/cts/Android.bp
index 96056c6..ce770e0 100644
--- a/thread/tests/cts/Android.bp
+++ b/thread/tests/cts/Android.bp
@@ -37,8 +37,9 @@
         "androidx.test.ext.junit",
         "compatibility-device-util-axt",
         "ctstestrunner-axt",
+        "guava-android-testlib",
         "net-tests-utils",
-        "truth-prebuilt",
+        "truth",
     ],
     libs: [
         "android.test.base",
diff --git a/thread/tests/cts/AndroidTest.xml b/thread/tests/cts/AndroidTest.xml
index 5ba605f..ffc181c 100644
--- a/thread/tests/cts/AndroidTest.xml
+++ b/thread/tests/cts/AndroidTest.xml
@@ -47,5 +47,7 @@
 
     <test class="com.android.tradefed.testtype.AndroidJUnitTest" >
         <option name="package" value="android.net.thread.cts" />
+        <!-- Ignores tests introduced by guava-android-testlib -->
+        <option name="exclude-annotation" value="org.junit.Ignore"/>
     </test>
 </configuration>
diff --git a/thread/tests/cts/src/android/net/thread/cts/ActiveOperationalDatasetTest.java b/thread/tests/cts/src/android/net/thread/cts/ActiveOperationalDatasetTest.java
new file mode 100644
index 0000000..39df21b
--- /dev/null
+++ b/thread/tests/cts/src/android/net/thread/cts/ActiveOperationalDatasetTest.java
@@ -0,0 +1,737 @@
+/*
+ * 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.thread.cts;
+
+import static android.net.thread.ActiveOperationalDataset.CHANNEL_PAGE_24_GHZ;
+
+import static com.android.testutils.ParcelUtils.assertParcelingIsLossless;
+
+import static com.google.common.io.BaseEncoding.base16;
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertThrows;
+
+import android.net.IpPrefix;
+import android.net.thread.ActiveOperationalDataset;
+import android.net.thread.ActiveOperationalDataset.Builder;
+import android.net.thread.ActiveOperationalDataset.SecurityPolicy;
+import android.net.thread.OperationalDatasetTimestamp;
+import android.util.SparseArray;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SmallTest;
+
+import com.google.common.primitives.Bytes;
+import com.google.common.testing.EqualsTester;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.io.ByteArrayOutputStream;
+import java.util.Arrays;
+
+/** CTS tests for {@link ActiveOperationalDataset}. */
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public final class ActiveOperationalDatasetTest {
+    private static final int TYPE_ACTIVE_TIMESTAMP = 14;
+    private static final int TYPE_CHANNEL = 0;
+    private static final int TYPE_CHANNEL_MASK = 53;
+    private static final int TYPE_EXTENDED_PAN_ID = 2;
+    private static final int TYPE_MESH_LOCAL_PREFIX = 7;
+    private static final int TYPE_NETWORK_KEY = 5;
+    private static final int TYPE_NETWORK_NAME = 3;
+    private static final int TYPE_PAN_ID = 1;
+    private static final int TYPE_PSKC = 4;
+    private static final int TYPE_SECURITY_POLICY = 12;
+
+    // A valid Thread Active Operational Dataset generated from OpenThread CLI "dataset new":
+    // Active Timestamp: 1
+    // Channel: 19
+    // Channel Mask: 0x07FFF800
+    // Ext PAN ID: ACC214689BC40BDF
+    // Mesh Local Prefix: fd64:db12:25f4:7e0b::/64
+    // Network Key: F26B3153760F519A63BAFDDFFC80D2AF
+    // Network Name: OpenThread-d9a0
+    // PAN ID: 0xD9A0
+    // PSKc: A245479C836D551B9CA557F7B9D351B4
+    // Security Policy: 672 onrcb
+    private static final byte[] VALID_DATASET =
+            base16().decode(
+                            "0E080000000000010000000300001335060004001FFFE002"
+                                    + "08ACC214689BC40BDF0708FD64DB1225F47E0B0510F26B31"
+                                    + "53760F519A63BAFDDFFC80D2AF030F4F70656E5468726561"
+                                    + "642D643961300102D9A00410A245479C836D551B9CA557F7"
+                                    + "B9D351B40C0402A0FFF8");
+
+    private static byte[] removeTlv(byte[] dataset, int type) {
+        ByteArrayOutputStream os = new ByteArrayOutputStream(dataset.length);
+        int i = 0;
+        while (i < dataset.length) {
+            int ty = dataset[i++] & 0xff;
+            byte length = dataset[i++];
+            if (ty != type) {
+                byte[] value = Arrays.copyOfRange(dataset, i, i + length);
+                os.write(ty);
+                os.write(length);
+                os.writeBytes(value);
+            }
+            i += length;
+        }
+        return os.toByteArray();
+    }
+
+    private static byte[] addTlv(byte[] dataset, String tlvHex) {
+        return Bytes.concat(dataset, base16().decode(tlvHex));
+    }
+
+    private static byte[] replaceTlv(byte[] dataset, int type, String newTlvHex) {
+        return addTlv(removeTlv(dataset, type), newTlvHex);
+    }
+
+    @Test
+    public void parcelable_parcelingIsLossLess() {
+        ActiveOperationalDataset dataset = ActiveOperationalDataset.fromThreadTlvs(VALID_DATASET);
+
+        assertParcelingIsLossless(dataset);
+    }
+
+    @Test
+    public void fromThreadTlvs_tooLongTlv_throwsIllegalArgument() {
+        byte[] invalidTlv = new byte[255];
+        invalidTlv[0] = (byte) 0xff;
+
+        // This is invalid because the TLV has max total length of 254 bytes and the value length
+        // can't exceeds 252 ( = 254 - 1 - 1)
+        invalidTlv[1] = (byte) 253;
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_invalidNetworkKeyTlv_throwsIllegalArgument() {
+        byte[] invalidTlv = replaceTlv(VALID_DATASET, TYPE_NETWORK_KEY, "05080000000000000000");
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_noNetworkKeyTlv_throwsIllegalArgument() {
+        byte[] invalidTlv = removeTlv(VALID_DATASET, TYPE_NETWORK_KEY);
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_invalidActiveTimestampTlv_throwsIllegalArgument() {
+        byte[] invalidTlv = replaceTlv(VALID_DATASET, TYPE_ACTIVE_TIMESTAMP, "0E0700000000010000");
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_noActiveTimestampTlv_throwsIllegalArgument() {
+        byte[] invalidTlv = removeTlv(VALID_DATASET, TYPE_ACTIVE_TIMESTAMP);
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_invalidNetworkNameTlv_emptyName_throwsIllegalArgument() {
+        byte[] invalidTlv = replaceTlv(VALID_DATASET, TYPE_NETWORK_NAME, "0300");
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_invalidNetworkNameTlv_tooLongName_throwsIllegalArgument() {
+        byte[] invalidTlv =
+                replaceTlv(
+                        VALID_DATASET, TYPE_NETWORK_NAME, "03114142434445464748494A4B4C4D4E4F5051");
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_noNetworkNameTlv_throwsIllegalArgument() {
+        byte[] invalidTlv = removeTlv(VALID_DATASET, TYPE_NETWORK_NAME);
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_invalidChannelTlv_channelMissing_throwsIllegalArgument() {
+        byte[] invalidTlv = replaceTlv(VALID_DATASET, TYPE_CHANNEL, "000100");
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_undefinedChannelPage_success() {
+        byte[] datasetTlv = replaceTlv(VALID_DATASET, TYPE_CHANNEL, "0003010020");
+
+        ActiveOperationalDataset dataset = ActiveOperationalDataset.fromThreadTlvs(datasetTlv);
+
+        assertThat(dataset.getChannelPage()).isEqualTo(0x01);
+        assertThat(dataset.getChannel()).isEqualTo(0x20);
+    }
+
+    @Test
+    public void fromThreadTlvs_invalid2P4GhzChannel_throwsIllegalArgument() {
+        byte[] invalidTlv1 = replaceTlv(VALID_DATASET, TYPE_CHANNEL, "000300000A");
+        byte[] invalidTlv2 = replaceTlv(VALID_DATASET, TYPE_CHANNEL, "000300001B");
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv1));
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv2));
+    }
+
+    @Test
+    public void fromThreadTlvs_valid2P4GhzChannelTlv_success() {
+        byte[] validTlv = replaceTlv(VALID_DATASET, TYPE_CHANNEL, "0003000010");
+
+        ActiveOperationalDataset dataset = ActiveOperationalDataset.fromThreadTlvs(validTlv);
+
+        assertThat(dataset.getChannel()).isEqualTo(16);
+    }
+
+    @Test
+    public void fromThreadTlvs_noChannelTlv_throwsIllegalArgument() {
+        byte[] invalidTlv = removeTlv(VALID_DATASET, TYPE_CHANNEL);
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_prematureEndOfChannelMaskEntry_throwsIllegalArgument() {
+        byte[] invalidTlv = replaceTlv(VALID_DATASET, TYPE_CHANNEL_MASK, "350100");
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_inconsistentChannelMaskLength_throwsIllegalArgument() {
+        byte[] invalidTlv = replaceTlv(VALID_DATASET, TYPE_CHANNEL_MASK, "3506000500010000");
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_unsupportedChannelMaskLength_success() {
+        ActiveOperationalDataset dataset =
+                ActiveOperationalDataset.fromThreadTlvs(
+                        replaceTlv(VALID_DATASET, TYPE_CHANNEL_MASK, "350700050001000000"));
+
+        SparseArray<byte[]> channelMask = dataset.getChannelMask();
+        assertThat(channelMask.size()).isEqualTo(1);
+        assertThat(channelMask.get(CHANNEL_PAGE_24_GHZ))
+                .isEqualTo(new byte[] {0x00, 0x01, 0x00, 0x00, 0x00});
+    }
+
+    @Test
+    public void fromThreadTlvs_noChannelMaskTlv_throwsIllegalArgument() {
+        byte[] invalidTlv = removeTlv(VALID_DATASET, TYPE_CHANNEL_MASK);
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_invalidPanIdTlv_throwsIllegalArgument() {
+        byte[] invalidTlv = replaceTlv(VALID_DATASET, TYPE_PAN_ID, "010101");
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_noPanIdTlv_throwsIllegalArgument() {
+        byte[] invalidTlv = removeTlv(VALID_DATASET, TYPE_PAN_ID);
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_invalidExtendedPanIdTlv_throwsIllegalArgument() {
+        byte[] invalidTlv = replaceTlv(VALID_DATASET, TYPE_EXTENDED_PAN_ID, "020700010203040506");
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_noExtendedPanIdTlv_throwsIllegalArgument() {
+        byte[] invalidTlv = removeTlv(VALID_DATASET, TYPE_EXTENDED_PAN_ID);
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_invalidPskcTlv_throwsIllegalArgument() {
+        byte[] invalidTlv =
+                replaceTlv(VALID_DATASET, TYPE_PSKC, "0411000102030405060708090A0B0C0D0E0F10");
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_noPskcTlv_throwsIllegalArgument() {
+        byte[] invalidTlv = removeTlv(VALID_DATASET, TYPE_PSKC);
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_invalidMeshLocalPrefixTlv_throwsIllegalArgument() {
+        byte[] invalidTlv =
+                replaceTlv(VALID_DATASET, TYPE_MESH_LOCAL_PREFIX, "0709FD0001020304050607");
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_noMeshLocalPrefixTlv_throwsIllegalArgument() {
+        byte[] invalidTlv = removeTlv(VALID_DATASET, TYPE_MESH_LOCAL_PREFIX);
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_tooShortSecurityPolicyTlv_throwsIllegalArgument() {
+        byte[] invalidTlv = replaceTlv(VALID_DATASET, TYPE_SECURITY_POLICY, "0C0101");
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_noSecurityPolicyTlv_throwsIllegalArgument() {
+        byte[] invalidTlv = removeTlv(VALID_DATASET, TYPE_SECURITY_POLICY);
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_lengthAndDataMissing_throwsIllegalArgument() {
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(new byte[] {(byte) 0x00}));
+    }
+
+    @Test
+    public void fromThreadTlvs_prematureEndOfData_throwsIllegalArgument() {
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(new byte[] {0x00, 0x03, 0x00, 0x00}));
+    }
+
+    @Test
+    public void fromThreadTlvs_validFullDataset_success() {
+        // A valid Thread active operational dataset:
+        // Active Timestamp: 1
+        // Channel: 19
+        // Channel Mask: 0x07FFF800
+        // Ext PAN ID: ACC214689BC40BDF
+        // Mesh Local Prefix: fd64:db12:25f4:7e0b::/64
+        // Network Key: F26B3153760F519A63BAFDDFFC80D2AF
+        // Network Name: OpenThread-d9a0
+        // PAN ID: 0xD9A0
+        // PSKc: A245479C836D551B9CA557F7B9D351B4
+        // Security Policy: 672 onrcb
+        byte[] validDatasetTlv =
+                base16().decode(
+                                "0E080000000000010000000300001335060004001FFFE002"
+                                        + "08ACC214689BC40BDF0708FD64DB1225F47E0B0510F26B31"
+                                        + "53760F519A63BAFDDFFC80D2AF030F4F70656E5468726561"
+                                        + "642D643961300102D9A00410A245479C836D551B9CA557F7"
+                                        + "B9D351B40C0402A0FFF8");
+
+        ActiveOperationalDataset dataset = ActiveOperationalDataset.fromThreadTlvs(validDatasetTlv);
+
+        assertThat(dataset.getNetworkKey())
+                .isEqualTo(base16().decode("F26B3153760F519A63BAFDDFFC80D2AF"));
+        assertThat(dataset.getPanId()).isEqualTo(0xd9a0);
+        assertThat(dataset.getExtendedPanId()).isEqualTo(base16().decode("ACC214689BC40BDF"));
+        assertThat(dataset.getChannel()).isEqualTo(19);
+        assertThat(dataset.getNetworkName()).isEqualTo("OpenThread-d9a0");
+        assertThat(dataset.getPskc())
+                .isEqualTo(base16().decode("A245479C836D551B9CA557F7B9D351B4"));
+        assertThat(dataset.getActiveTimestamp())
+                .isEqualTo(new OperationalDatasetTimestamp(1, 0, false));
+        SparseArray<byte[]> channelMask = dataset.getChannelMask();
+        assertThat(channelMask.size()).isEqualTo(1);
+        assertThat(channelMask.get(CHANNEL_PAGE_24_GHZ))
+                .isEqualTo(new byte[] {0x00, 0x1f, (byte) 0xff, (byte) 0xe0});
+        assertThat(dataset.getMeshLocalPrefix())
+                .isEqualTo(new IpPrefix("fd64:db12:25f4:7e0b::/64"));
+        assertThat(dataset.getSecurityPolicy())
+                .isEqualTo(new SecurityPolicy(672, new byte[] {(byte) 0xff, (byte) 0xf8}));
+    }
+
+    @Test
+    public void fromThreadTlvs_containsUnknownTlvs_unknownTlvsRetained() {
+        final byte[] datasetWithUnknownTlvs = addTlv(VALID_DATASET, "AA01FFBB020102");
+
+        ActiveOperationalDataset dataset =
+                ActiveOperationalDataset.fromThreadTlvs(datasetWithUnknownTlvs);
+
+        byte[] newDatasetTlvs = dataset.toThreadTlvs();
+        String newDatasetTlvsHex = base16().encode(newDatasetTlvs);
+        assertThat(newDatasetTlvs.length).isEqualTo(datasetWithUnknownTlvs.length);
+        assertThat(newDatasetTlvsHex).contains("AA01FF");
+        assertThat(newDatasetTlvsHex).contains("BB020102");
+    }
+
+    @Test
+    public void toThreadTlvs_conversionIsLossLess() {
+        ActiveOperationalDataset dataset1 = ActiveOperationalDataset.createRandomDataset();
+
+        ActiveOperationalDataset dataset2 =
+                ActiveOperationalDataset.fromThreadTlvs(dataset1.toThreadTlvs());
+
+        assertThat(dataset2).isEqualTo(dataset1);
+    }
+
+    @Test
+    public void builder_buildWithdefaultValues_throwsIllegalState() {
+        assertThrows(IllegalStateException.class, () -> new Builder().build());
+    }
+
+    @Test
+    public void builder_setValidNetworkKey_success() {
+        final byte[] networkKey =
+                new byte[] {
+                    0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c,
+                    0x0d, 0x0e, 0x0f
+                };
+
+        ActiveOperationalDataset dataset =
+                new Builder(ActiveOperationalDataset.createRandomDataset())
+                        .setNetworkKey(networkKey)
+                        .build();
+
+        assertThat(dataset.getNetworkKey()).isEqualTo(networkKey);
+    }
+
+    @Test
+    public void builder_setInvalidNetworkKey_throwsIllegalArgument() {
+        byte[] invalidNetworkKey = new byte[] {0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07};
+        Builder builder = new Builder(ActiveOperationalDataset.createRandomDataset());
+
+        assertThrows(
+                IllegalArgumentException.class, () -> builder.setNetworkKey(invalidNetworkKey));
+    }
+
+    @Test
+    public void builder_setValidExtendedPanId_success() {
+        byte[] extendedPanId = new byte[] {0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07};
+
+        ActiveOperationalDataset dataset =
+                new Builder(ActiveOperationalDataset.createRandomDataset())
+                        .setExtendedPanId(extendedPanId)
+                        .build();
+
+        assertThat(dataset.getExtendedPanId()).isEqualTo(extendedPanId);
+    }
+
+    @Test
+    public void builder_setInvalidExtendedPanId_throwsIllegalArgument() {
+        byte[] extendedPanId = new byte[] {0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06};
+        Builder builder = new Builder(ActiveOperationalDataset.createRandomDataset());
+
+        assertThrows(IllegalArgumentException.class, () -> builder.setExtendedPanId(extendedPanId));
+    }
+
+    @Test
+    public void builder_setValidPanId_success() {
+        ActiveOperationalDataset dataset =
+                new Builder(ActiveOperationalDataset.createRandomDataset())
+                        .setPanId(0xfffe)
+                        .build();
+
+        assertThat(dataset.getPanId()).isEqualTo(0xfffe);
+    }
+
+    @Test
+    public void builder_setInvalidPanId_throwsIllegalArgument() {
+        Builder builder = new Builder(ActiveOperationalDataset.createRandomDataset());
+
+        assertThrows(IllegalArgumentException.class, () -> builder.setPanId(0xffff));
+    }
+
+    @Test
+    public void builder_setInvalidChannel_throwsIllegalArgument() {
+        Builder builder = new Builder(ActiveOperationalDataset.createRandomDataset());
+
+        assertThrows(IllegalArgumentException.class, () -> builder.setChannel(0, 0));
+        assertThrows(IllegalArgumentException.class, () -> builder.setChannel(0, 27));
+    }
+
+    @Test
+    public void builder_setValid2P4GhzChannel_success() {
+        ActiveOperationalDataset dataset =
+                new Builder(ActiveOperationalDataset.createRandomDataset())
+                        .setChannel(CHANNEL_PAGE_24_GHZ, 16)
+                        .build();
+
+        assertThat(dataset.getChannel()).isEqualTo(16);
+        assertThat(dataset.getChannelPage()).isEqualTo(CHANNEL_PAGE_24_GHZ);
+    }
+
+    @Test
+    public void builder_setValidNetworkName_success() {
+        ActiveOperationalDataset dataset =
+                new Builder(ActiveOperationalDataset.createRandomDataset())
+                        .setNetworkName("ot-network")
+                        .build();
+
+        assertThat(dataset.getNetworkName()).isEqualTo("ot-network");
+    }
+
+    @Test
+    public void builder_setEmptyNetworkName_throwsIllegalArgument() {
+        Builder builder = new Builder(ActiveOperationalDataset.createRandomDataset());
+
+        assertThrows(IllegalArgumentException.class, () -> builder.setNetworkName(""));
+    }
+
+    @Test
+    public void builder_setTooLongNetworkName_throwsIllegalArgument() {
+        Builder builder = new Builder(ActiveOperationalDataset.createRandomDataset());
+
+        assertThrows(
+                IllegalArgumentException.class, () -> builder.setNetworkName("openthread-network"));
+    }
+
+    @Test
+    public void builder_setTooLongUtf8NetworkName_throwsIllegalArgument() {
+        Builder builder = new Builder(ActiveOperationalDataset.createRandomDataset());
+
+        // UTF-8 encoded length of "我的线程网络" is 18 bytes which exceeds the max length
+        assertThrows(IllegalArgumentException.class, () -> builder.setNetworkName("我的线程网络"));
+    }
+
+    @Test
+    public void builder_setValidUtf8NetworkName_success() {
+        ActiveOperationalDataset dataset =
+                new Builder(ActiveOperationalDataset.createRandomDataset())
+                        .setNetworkName("我的网络")
+                        .build();
+
+        assertThat(dataset.getNetworkName()).isEqualTo("我的网络");
+    }
+
+    @Test
+    public void builder_setValidPskc_success() {
+        byte[] pskc = base16().decode("A245479C836D551B9CA557F7B9D351B4");
+
+        ActiveOperationalDataset dataset =
+                new Builder(ActiveOperationalDataset.createRandomDataset()).setPskc(pskc).build();
+
+        assertThat(dataset.getPskc()).isEqualTo(pskc);
+    }
+
+    @Test
+    public void builder_setTooLongPskc_throwsIllegalArgument() {
+        byte[] tooLongPskc = base16().decode("A245479C836D551B9CA557F7B9D351B400");
+        Builder builder = new Builder(ActiveOperationalDataset.createRandomDataset());
+
+        assertThrows(IllegalArgumentException.class, () -> builder.setPskc(tooLongPskc));
+    }
+
+    @Test
+    public void builder_setValidChannelMask_success() {
+        Builder builder = new Builder(ActiveOperationalDataset.createRandomDataset());
+        SparseArray<byte[]> channelMask = new SparseArray<byte[]>(1);
+        channelMask.put(0, new byte[] {0x00, 0x00, 0x01, 0x00});
+
+        ActiveOperationalDataset dataset = builder.setChannelMask(channelMask).build();
+
+        SparseArray<byte[]> resultChannelMask = dataset.getChannelMask();
+        assertThat(resultChannelMask.size()).isEqualTo(1);
+        assertThat(resultChannelMask.get(0)).isEqualTo(new byte[] {0x00, 0x00, 0x01, 0x00});
+    }
+
+    @Test
+    public void builder_setEmptyChannelMask_throwsIllegalArgument() {
+        Builder builder = new Builder(ActiveOperationalDataset.createRandomDataset());
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> builder.setChannelMask(new SparseArray<byte[]>()));
+    }
+
+    @Test
+    public void builder_setValidActiveTimestamp_success() {
+        ActiveOperationalDataset dataset =
+                new Builder(ActiveOperationalDataset.createRandomDataset())
+                        .setActiveTimestamp(
+                                new OperationalDatasetTimestamp(
+                                        /* seconds= */ 1,
+                                        /* ticks= */ 0,
+                                        /* isAuthoritativeSource= */ true))
+                        .build();
+
+        assertThat(dataset.getActiveTimestamp().getSeconds()).isEqualTo(1);
+        assertThat(dataset.getActiveTimestamp().getTicks()).isEqualTo(0);
+        assertThat(dataset.getActiveTimestamp().isAuthoritativeSource()).isTrue();
+    }
+
+    @Test
+    public void builder_wrongMeshLocalPrefixLength_throwsIllegalArguments() {
+        Builder builder = new Builder(ActiveOperationalDataset.createRandomDataset());
+
+        // The Mesh-Local Prefix length must be 64 bits
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> builder.setMeshLocalPrefix(new IpPrefix("fd00::/32")));
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> builder.setMeshLocalPrefix(new IpPrefix("fd00::/96")));
+
+        // The Mesh-Local Prefix must start with 0xfd
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> builder.setMeshLocalPrefix(new IpPrefix("fc00::/64")));
+    }
+
+    @Test
+    public void builder_meshLocalPrefixNotStartWith0xfd_throwsIllegalArguments() {
+        Builder builder = new Builder(ActiveOperationalDataset.createRandomDataset());
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> builder.setMeshLocalPrefix(new IpPrefix("fc00::/64")));
+    }
+
+    @Test
+    public void builder_setValidMeshLocalPrefix_success() {
+        ActiveOperationalDataset dataset =
+                new Builder(ActiveOperationalDataset.createRandomDataset())
+                        .setMeshLocalPrefix(new IpPrefix("fd00::/64"))
+                        .build();
+
+        assertThat(dataset.getMeshLocalPrefix()).isEqualTo(new IpPrefix("fd00::/64"));
+    }
+
+    @Test
+    public void builder_setValid1P2SecurityPolicy_success() {
+        ActiveOperationalDataset dataset =
+                new Builder(ActiveOperationalDataset.createRandomDataset())
+                        .setSecurityPolicy(
+                                new SecurityPolicy(672, new byte[] {(byte) 0xff, (byte) 0xf8}))
+                        .build();
+
+        assertThat(dataset.getSecurityPolicy().getRotationTimeHours()).isEqualTo(672);
+        assertThat(dataset.getSecurityPolicy().getFlags())
+                .isEqualTo(new byte[] {(byte) 0xff, (byte) 0xf8});
+    }
+
+    @Test
+    public void builder_setValid1P1SecurityPolicy_success() {
+        ActiveOperationalDataset dataset =
+                new Builder(ActiveOperationalDataset.createRandomDataset())
+                        .setSecurityPolicy(new SecurityPolicy(672, new byte[] {(byte) 0xff}))
+                        .build();
+
+        assertThat(dataset.getSecurityPolicy().getRotationTimeHours()).isEqualTo(672);
+        assertThat(dataset.getSecurityPolicy().getFlags()).isEqualTo(new byte[] {(byte) 0xff});
+    }
+
+    @Test
+    public void securityPolicy_invalidRotationTime_throwsIllegalArguments() {
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> new SecurityPolicy(0, new byte[] {(byte) 0xff, (byte) 0xf8}));
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> new SecurityPolicy(0x1ffff, new byte[] {(byte) 0xff, (byte) 0xf8}));
+    }
+
+    @Test
+    public void securityPolicy_emptyFlags_throwsIllegalArguments() {
+        assertThrows(IllegalArgumentException.class, () -> new SecurityPolicy(672, new byte[] {}));
+    }
+
+    @Test
+    public void securityPolicy_tooLongFlags_success() {
+        SecurityPolicy securityPolicy =
+                new SecurityPolicy(672, new byte[] {0, 1, 2, 3, 4, 5, 6, 7});
+
+        assertThat(securityPolicy.getFlags()).isEqualTo(new byte[] {0, 1, 2, 3, 4, 5, 6, 7});
+    }
+
+    @Test
+    public void securityPolicy_equals() {
+        new EqualsTester()
+                .addEqualityGroup(
+                        new SecurityPolicy(672, new byte[] {(byte) 0xff, (byte) 0xf8}),
+                        new SecurityPolicy(672, new byte[] {(byte) 0xff, (byte) 0xf8}))
+                .addEqualityGroup(
+                        new SecurityPolicy(1, new byte[] {(byte) 0xff}),
+                        new SecurityPolicy(1, new byte[] {(byte) 0xff}))
+                .addEqualityGroup(
+                        new SecurityPolicy(1, new byte[] {(byte) 0xff, (byte) 0xf8}),
+                        new SecurityPolicy(1, new byte[] {(byte) 0xff, (byte) 0xf8}))
+                .testEquals();
+    }
+}
diff --git a/thread/tests/cts/src/android/net/thread/cts/OperationalDatasetTimestampTest.java b/thread/tests/cts/src/android/net/thread/cts/OperationalDatasetTimestampTest.java
new file mode 100644
index 0000000..9be3d56
--- /dev/null
+++ b/thread/tests/cts/src/android/net/thread/cts/OperationalDatasetTimestampTest.java
@@ -0,0 +1,127 @@
+/*
+ * 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.thread.cts;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertThrows;
+
+import android.net.thread.OperationalDatasetTimestamp;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SmallTest;
+
+import com.google.common.testing.EqualsTester;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.time.Instant;
+
+/** Tests for {@link OperationalDatasetTimestamp}. */
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public final class OperationalDatasetTimestampTest {
+    @Test
+    public void fromInstant_tooLargeInstant_throwsIllegalArgument() {
+        assertThrows(
+                IllegalArgumentException.class,
+                () ->
+                        OperationalDatasetTimestamp.fromInstant(
+                                Instant.ofEpochSecond(0xffffffffffffL + 1L)));
+    }
+
+    @Test
+    public void fromInstant_ticksIsRounded() {
+        Instant instant = Instant.ofEpochSecond(100L);
+
+        // 32767.5 / 32768 * 1000000000 = 999984741.2109375 and given the `ticks` is rounded, so
+        // the `ticks` should be 32767 for 999984741 and 0 (carried over to seconds) for 999984742.
+        OperationalDatasetTimestamp timestampTicks32767 =
+                OperationalDatasetTimestamp.fromInstant(instant.plusNanos(999984741));
+        OperationalDatasetTimestamp timestampTicks0 =
+                OperationalDatasetTimestamp.fromInstant(instant.plusNanos(999984742));
+
+        assertThat(timestampTicks32767.getSeconds()).isEqualTo(100L);
+        assertThat(timestampTicks0.getSeconds()).isEqualTo(101L);
+        assertThat(timestampTicks32767.getTicks()).isEqualTo(32767);
+        assertThat(timestampTicks0.getTicks()).isEqualTo(0);
+        assertThat(timestampTicks32767.isAuthoritativeSource()).isTrue();
+        assertThat(timestampTicks0.isAuthoritativeSource()).isTrue();
+    }
+
+    @Test
+    public void toInstant_nanosIsRounded() {
+        // 32767 / 32768 * 1000000000 = 999969482.421875
+        assertThat(new OperationalDatasetTimestamp(100L, 32767, false).toInstant().getNano())
+                .isEqualTo(999969482);
+
+        // 32766 / 32768 * 1000000000 = 999938964.84375
+        assertThat(new OperationalDatasetTimestamp(100L, 32766, false).toInstant().getNano())
+                .isEqualTo(999938965);
+    }
+
+    @Test
+    public void toInstant_onlyAuthoritativeSourceDiscarded() {
+        OperationalDatasetTimestamp timestamp1 =
+                new OperationalDatasetTimestamp(100L, 0x7fff, false);
+
+        OperationalDatasetTimestamp timestamp2 =
+                OperationalDatasetTimestamp.fromInstant(timestamp1.toInstant());
+
+        assertThat(timestamp2.getSeconds()).isEqualTo(100L);
+        assertThat(timestamp2.getTicks()).isEqualTo(0x7fff);
+        assertThat(timestamp2.isAuthoritativeSource()).isTrue();
+    }
+
+    @Test
+    public void constructor_tooLargeSeconds_throwsIllegalArguments() {
+        assertThrows(
+                IllegalArgumentException.class,
+                () ->
+                        new OperationalDatasetTimestamp(
+                                /* seconds= */ 0x0001112233445566L,
+                                /* ticks= */ 0,
+                                /* isAuthoritativeSource= */ true));
+    }
+
+    @Test
+    public void constructor_tooLargeTicks_throwsIllegalArguments() {
+        assertThrows(
+                IllegalArgumentException.class,
+                () ->
+                        new OperationalDatasetTimestamp(
+                                /* seconds= */ 0x01L,
+                                /* ticks= */ 0x8000,
+                                /* isAuthoritativeSource= */ true));
+    }
+
+    @Test
+    public void equalityTests() {
+        new EqualsTester()
+                .addEqualityGroup(
+                        new OperationalDatasetTimestamp(100, 100, false),
+                        new OperationalDatasetTimestamp(100, 100, false))
+                .addEqualityGroup(
+                        new OperationalDatasetTimestamp(0, 0, false),
+                        new OperationalDatasetTimestamp(0, 0, false))
+                .addEqualityGroup(
+                        new OperationalDatasetTimestamp(0xffffffffffffL, 0x7fff, true),
+                        new OperationalDatasetTimestamp(0xffffffffffffL, 0x7fff, true))
+                .testEquals();
+    }
+}
diff --git a/thread/tests/cts/src/android/net/thread/cts/PendingOperationalDatasetTest.java b/thread/tests/cts/src/android/net/thread/cts/PendingOperationalDatasetTest.java
new file mode 100644
index 0000000..7a49957
--- /dev/null
+++ b/thread/tests/cts/src/android/net/thread/cts/PendingOperationalDatasetTest.java
@@ -0,0 +1,247 @@
+/*
+ * 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.thread.cts;
+
+import static com.android.testutils.ParcelUtils.assertParcelingIsLossless;
+
+import static com.google.common.io.BaseEncoding.base16;
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertThrows;
+
+import android.net.IpPrefix;
+import android.net.thread.ActiveOperationalDataset;
+import android.net.thread.OperationalDatasetTimestamp;
+import android.net.thread.PendingOperationalDataset;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SmallTest;
+
+import com.google.common.primitives.Bytes;
+import com.google.common.testing.EqualsTester;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.time.Duration;
+
+/** Tests for {@link PendingOperationalDataset}. */
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public final class PendingOperationalDatasetTest {
+    private static final ActiveOperationalDataset DEFAULT_ACTIVE_DATASET =
+            ActiveOperationalDataset.createRandomDataset();
+
+    @Test
+    public void parcelable_parcelingIsLossLess() {
+        PendingOperationalDataset dataset =
+                new PendingOperationalDataset(
+                        DEFAULT_ACTIVE_DATASET,
+                        new OperationalDatasetTimestamp(31536000, 200, false),
+                        Duration.ofHours(100));
+
+        assertParcelingIsLossless(dataset);
+    }
+
+    @Test
+    public void equalityTests() {
+        ActiveOperationalDataset activeDataset1 = ActiveOperationalDataset.createRandomDataset();
+        ActiveOperationalDataset activeDataset2 = ActiveOperationalDataset.createRandomDataset();
+
+        new EqualsTester()
+                .addEqualityGroup(
+                        new PendingOperationalDataset(
+                                activeDataset1,
+                                new OperationalDatasetTimestamp(31536000, 100, false),
+                                Duration.ofMillis(0)),
+                        new PendingOperationalDataset(
+                                activeDataset1,
+                                new OperationalDatasetTimestamp(31536000, 100, false),
+                                Duration.ofMillis(0)))
+                .addEqualityGroup(
+                        new PendingOperationalDataset(
+                                activeDataset2,
+                                new OperationalDatasetTimestamp(31536000, 100, false),
+                                Duration.ofMillis(0)),
+                        new PendingOperationalDataset(
+                                activeDataset2,
+                                new OperationalDatasetTimestamp(31536000, 100, false),
+                                Duration.ofMillis(0)))
+                .addEqualityGroup(
+                        new PendingOperationalDataset(
+                                activeDataset2,
+                                new OperationalDatasetTimestamp(15768000, 0, false),
+                                Duration.ofMillis(0)),
+                        new PendingOperationalDataset(
+                                activeDataset2,
+                                new OperationalDatasetTimestamp(15768000, 0, false),
+                                Duration.ofMillis(0)))
+                .addEqualityGroup(
+                        new PendingOperationalDataset(
+                                activeDataset2,
+                                new OperationalDatasetTimestamp(15768000, 0, false),
+                                Duration.ofMillis(100)),
+                        new PendingOperationalDataset(
+                                activeDataset2,
+                                new OperationalDatasetTimestamp(15768000, 0, false),
+                                Duration.ofMillis(100)))
+                .testEquals();
+    }
+
+    @Test
+    public void constructor_correctValuesAreSet() {
+        PendingOperationalDataset dataset =
+                new PendingOperationalDataset(
+                        DEFAULT_ACTIVE_DATASET,
+                        new OperationalDatasetTimestamp(31536000, 200, false),
+                        Duration.ofHours(100));
+
+        assertThat(dataset.getActiveOperationalDataset()).isEqualTo(DEFAULT_ACTIVE_DATASET);
+        assertThat(dataset.getPendingTimestamp())
+                .isEqualTo(new OperationalDatasetTimestamp(31536000, 200, false));
+        assertThat(dataset.getDelayTimer()).isEqualTo(Duration.ofHours(100));
+    }
+
+    @Test
+    public void fromThreadTlvs_openthreadTlvs_success() {
+        // An example Pending Operational Dataset which is generated with OpenThread CLI:
+        // Pending Timestamp: 2
+        // Active Timestamp: 1
+        // Channel: 26
+        // Channel Mask: 0x07fff800
+        // Delay: 46354
+        // Ext PAN ID: a74182f4d3f4de41
+        // Mesh Local Prefix: fd46:c1b9:e159:5574::/64
+        // Network Key: ed916e454d96fd00184f10a6f5c9e1d3
+        // Network Name: OpenThread-bff8
+        // PAN ID: 0xbff8
+        // PSKc: 264f78414adc683191863d968f72d1b7
+        // Security Policy: 672 onrc
+        final byte[] OPENTHREAD_PENDING_DATASET_TLVS =
+                base16().lowerCase()
+                        .decode(
+                                "0e0800000000000100003308000000000002000034040000b51200030000"
+                                        + "1a35060004001fffe00208a74182f4d3f4de410708fd46c1b9"
+                                        + "e15955740510ed916e454d96fd00184f10a6f5c9e1d3030f4f"
+                                        + "70656e5468726561642d626666380102bff80410264f78414a"
+                                        + "dc683191863d968f72d1b70c0402a0f7f8");
+
+        PendingOperationalDataset pendingDataset =
+                PendingOperationalDataset.fromThreadTlvs(OPENTHREAD_PENDING_DATASET_TLVS);
+
+        ActiveOperationalDataset activeDataset = pendingDataset.getActiveOperationalDataset();
+        assertThat(pendingDataset.getPendingTimestamp().getSeconds()).isEqualTo(2L);
+        assertThat(activeDataset.getActiveTimestamp().getSeconds()).isEqualTo(1L);
+        assertThat(activeDataset.getChannel()).isEqualTo(26);
+        assertThat(activeDataset.getChannelMask().get(0))
+                .isEqualTo(new byte[] {0x00, 0x1f, (byte) 0xff, (byte) 0xe0});
+        assertThat(pendingDataset.getDelayTimer().toMillis()).isEqualTo(46354);
+        assertThat(activeDataset.getExtendedPanId())
+                .isEqualTo(base16().lowerCase().decode("a74182f4d3f4de41"));
+        assertThat(activeDataset.getMeshLocalPrefix())
+                .isEqualTo(new IpPrefix("fd46:c1b9:e159:5574::/64"));
+        assertThat(activeDataset.getNetworkKey())
+                .isEqualTo(base16().lowerCase().decode("ed916e454d96fd00184f10a6f5c9e1d3"));
+        assertThat(activeDataset.getNetworkName()).isEqualTo("OpenThread-bff8");
+        assertThat(activeDataset.getPanId()).isEqualTo(0xbff8);
+        assertThat(activeDataset.getPskc())
+                .isEqualTo(base16().lowerCase().decode("264f78414adc683191863d968f72d1b7"));
+        assertThat(activeDataset.getSecurityPolicy().getRotationTimeHours()).isEqualTo(672);
+        assertThat(activeDataset.getSecurityPolicy().getFlags())
+                .isEqualTo(new byte[] {(byte) 0xf7, (byte) 0xf8});
+    }
+
+    @Test
+    public void fromThreadTlvs_completePendingDatasetTlvs_success() {
+        // Type Length Value
+        // 0x33 0x08 0x0000000000010000 (Pending Timestamp TLV)
+        // 0x34 0x04 0x0000012C (Delay Timer TLV)
+        final byte[] pendingTimestampAndDelayTimerTlvs =
+                base16().decode("3308000000000001000034040000012C");
+        final byte[] pendingDatasetTlvs =
+                Bytes.concat(
+                        pendingTimestampAndDelayTimerTlvs, DEFAULT_ACTIVE_DATASET.toThreadTlvs());
+
+        PendingOperationalDataset dataset =
+                PendingOperationalDataset.fromThreadTlvs(pendingDatasetTlvs);
+
+        assertThat(dataset.getActiveOperationalDataset()).isEqualTo(DEFAULT_ACTIVE_DATASET);
+        assertThat(dataset.getPendingTimestamp())
+                .isEqualTo(new OperationalDatasetTimestamp(1, 0, false));
+        assertThat(dataset.getDelayTimer()).isEqualTo(Duration.ofMillis(300));
+    }
+
+    @Test
+    public void fromThreadTlvs_PendingTimestampTlvIsMissing_throwsIllegalArgument() {
+        // Type Length Value
+        // 0x34 0x04 0x00000064 (Delay Timer TLV)
+        final byte[] pendingTimestampAndDelayTimerTlvs = base16().decode("34040000012C");
+        final byte[] pendingDatasetTlvs =
+                Bytes.concat(
+                        pendingTimestampAndDelayTimerTlvs, DEFAULT_ACTIVE_DATASET.toThreadTlvs());
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> PendingOperationalDataset.fromThreadTlvs(pendingDatasetTlvs));
+    }
+
+    @Test
+    public void fromThreadTlvs_delayTimerTlvIsMissing_throwsIllegalArgument() {
+        // Type Length Value
+        // 0x33 0x08 0x0000000000010000 (Pending Timestamp TLV)
+        final byte[] pendingTimestampAndDelayTimerTlvs = base16().decode("33080000000000010000");
+        final byte[] pendingDatasetTlvs =
+                Bytes.concat(
+                        pendingTimestampAndDelayTimerTlvs, DEFAULT_ACTIVE_DATASET.toThreadTlvs());
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> PendingOperationalDataset.fromThreadTlvs(pendingDatasetTlvs));
+    }
+
+    @Test
+    public void fromThreadTlvs_activeDatasetTlvs_throwsIllegalArgument() {
+        final byte[] activeDatasetTlvs = DEFAULT_ACTIVE_DATASET.toThreadTlvs();
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> PendingOperationalDataset.fromThreadTlvs(activeDatasetTlvs));
+    }
+
+    @Test
+    public void fromThreadTlvs_malformedTlvs_throwsIllegalArgument() {
+        final byte[] invalidTlvs = new byte[] {0x00};
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> PendingOperationalDataset.fromThreadTlvs(invalidTlvs));
+    }
+
+    @Test
+    public void toThreadTlvs_conversionIsLossLess() {
+        PendingOperationalDataset dataset1 =
+                new PendingOperationalDataset(
+                        DEFAULT_ACTIVE_DATASET,
+                        new OperationalDatasetTimestamp(31536000, 200, false),
+                        Duration.ofHours(100));
+
+        PendingOperationalDataset dataset2 =
+                PendingOperationalDataset.fromThreadTlvs(dataset1.toThreadTlvs());
+
+        assertThat(dataset2).isEqualTo(dataset1);
+    }
+}
diff --git a/thread/tests/unit/Android.bp b/thread/tests/unit/Android.bp
new file mode 100644
index 0000000..3a087c7
--- /dev/null
+++ b/thread/tests/unit/Android.bp
@@ -0,0 +1,50 @@
+//
+// 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_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+android_test {
+    name: "ThreadNetworkUnitTests",
+    min_sdk_version: "33",
+    sdk_version: "module_current",
+    manifest: "AndroidManifest.xml",
+    test_config: "AndroidTest.xml",
+    srcs: [
+        "src/**/*.java",
+    ],
+    test_suites: [
+        "general-tests",
+    ],
+    static_libs: [
+        "androidx.test.ext.junit",
+        "compatibility-device-util-axt",
+        "ctstestrunner-axt",
+        "framework-connectivity-pre-jarjar",
+        "framework-connectivity-t-pre-jarjar",
+        "guava-android-testlib",
+        "net-tests-utils",
+        "truth",
+    ],
+    libs: [
+        "android.test.base",
+        "android.test.runner",
+    ],
+    // Test coverage system runs on different devices. Need to
+    // compile for all architectures.
+    compile_multilib: "both",
+}
diff --git a/thread/tests/unit/AndroidManifest.xml b/thread/tests/unit/AndroidManifest.xml
new file mode 100644
index 0000000..ace7c52
--- /dev/null
+++ b/thread/tests/unit/AndroidManifest.xml
@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+    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.
+ -->
+
+<manifest
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    package="android.net.thread.unittests">
+
+    <application android:debuggable="true">
+        <uses-library android:name="android.test.runner" />
+    </application>
+
+    <instrumentation
+        android:name="androidx.test.runner.AndroidJUnitRunner"
+        android:targetPackage="android.net.thread.unittests"
+        android:label="Unit tests for android.net.thread" />
+</manifest>
diff --git a/thread/tests/unit/AndroidTest.xml b/thread/tests/unit/AndroidTest.xml
new file mode 100644
index 0000000..663ff74
--- /dev/null
+++ b/thread/tests/unit/AndroidTest.xml
@@ -0,0 +1,33 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+    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.
+ -->
+
+<configuration description="Config for Thread network unit test cases">
+    <option name="test-tag" value="ThreadNetworkUnitTests" />
+    <option name="test-suite-tag" value="apct" />
+
+    <target_preparer class="com.android.tradefed.targetprep.suite.SuiteApkInstaller">
+        <option name="test-file-name" value="ThreadNetworkUnitTests.apk" />
+        <option name="check-min-sdk" value="true" />
+        <option name="cleanup-apks" value="true" />
+    </target_preparer>
+
+    <test class="com.android.tradefed.testtype.AndroidJUnitTest" >
+        <option name="package" value="android.net.thread.unittests" />
+        <!-- Ignores tests introduced by guava-android-testlib -->
+        <option name="exclude-annotation" value="org.junit.Ignore"/>
+    </test>
+</configuration>
diff --git a/thread/tests/unit/src/android/net/thread/ActiveOperationalDatasetTest.java b/thread/tests/unit/src/android/net/thread/ActiveOperationalDatasetTest.java
new file mode 100644
index 0000000..78eb3d0
--- /dev/null
+++ b/thread/tests/unit/src/android/net/thread/ActiveOperationalDatasetTest.java
@@ -0,0 +1,202 @@
+/*
+ * 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.thread;
+
+import static com.google.common.io.BaseEncoding.base16;
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import android.net.IpPrefix;
+import android.net.thread.ActiveOperationalDataset.Builder;
+import android.net.thread.ActiveOperationalDataset.SecurityPolicy;
+import android.util.SparseArray;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SmallTest;
+
+import com.google.common.primitives.Bytes;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.security.SecureRandom;
+import java.util.Random;
+
+/** Unit tests for {@link ActiveOperationalDataset}. */
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public class ActiveOperationalDatasetTest {
+    // A valid Thread Active Operational Dataset generated from OpenThread CLI "dataset new":
+    // Active Timestamp: 1
+    // Channel: 19
+    // Channel Mask: 0x07FFF800
+    // Ext PAN ID: ACC214689BC40BDF
+    // Mesh Local Prefix: fd64:db12:25f4:7e0b::/64
+    // Network Key: F26B3153760F519A63BAFDDFFC80D2AF
+    // Network Name: OpenThread-d9a0
+    // PAN ID: 0xD9A0
+    // PSKc: A245479C836D551B9CA557F7B9D351B4
+    // Security Policy: 672 onrcb
+    private static final byte[] VALID_DATASET =
+            base16().decode(
+                            "0E080000000000010000000300001335060004001FFFE002"
+                                    + "08ACC214689BC40BDF0708FD64DB1225F47E0B0510F26B31"
+                                    + "53760F519A63BAFDDFFC80D2AF030F4F70656E5468726561"
+                                    + "642D643961300102D9A00410A245479C836D551B9CA557F7"
+                                    + "B9D351B40C0402A0FFF8");
+
+    @Mock private Random mockRandom;
+    @Mock private SecureRandom mockSecureRandom;
+
+    @Before
+    public void setUp() {
+        MockitoAnnotations.initMocks(this);
+    }
+
+    private static byte[] addTlv(byte[] dataset, String tlvHex) {
+        return Bytes.concat(dataset, base16().decode(tlvHex));
+    }
+
+    @Test
+    public void fromThreadTlvs_containsUnknownTlvs_unknownTlvsRetained() {
+        byte[] datasetWithUnknownTlvs = addTlv(VALID_DATASET, "AA01FFBB020102");
+
+        ActiveOperationalDataset dataset1 =
+                ActiveOperationalDataset.fromThreadTlvs(datasetWithUnknownTlvs);
+        ActiveOperationalDataset dataset2 =
+                ActiveOperationalDataset.fromThreadTlvs(dataset1.toThreadTlvs());
+
+        SparseArray<byte[]> unknownTlvs = dataset2.getUnknownTlvs();
+        assertThat(unknownTlvs.size()).isEqualTo(2);
+        assertThat(unknownTlvs.get(0xAA)).isEqualTo(new byte[] {(byte) 0xFF});
+        assertThat(unknownTlvs.get(0xBB)).isEqualTo(new byte[] {0x01, 0x02});
+        assertThat(dataset2).isEqualTo(dataset1);
+    }
+
+    @Test
+    public void createRandomDataset_fieldsAreRandomized() {
+        // Always return the max bounded value
+        doAnswer(invocation -> (int) invocation.getArgument(0) - 1)
+                .when(mockRandom)
+                .nextInt(anyInt());
+        doAnswer(
+                        invocation -> {
+                            byte[] output = invocation.getArgument(0);
+                            for (int i = 0; i < output.length; ++i) {
+                                output[i] = (byte) (i + 10);
+                            }
+                            return null;
+                        })
+                .when(mockRandom)
+                .nextBytes(any(byte[].class));
+        doAnswer(
+                        invocation -> {
+                            byte[] output = invocation.getArgument(0);
+                            for (int i = 0; i < output.length; ++i) {
+                                output[i] = (byte) (i + 30);
+                            }
+                            return null;
+                        })
+                .when(mockSecureRandom)
+                .nextBytes(any(byte[].class));
+
+        ActiveOperationalDataset dataset =
+                ActiveOperationalDataset.createRandomDataset(mockRandom, mockSecureRandom);
+
+        assertThat(dataset.getActiveTimestamp())
+                .isEqualTo(new OperationalDatasetTimestamp(1, 0, false));
+        assertThat(dataset.getExtendedPanId())
+                .isEqualTo(new byte[] {10, 11, 12, 13, 14, 15, 16, 17});
+        assertThat(dataset.getMeshLocalPrefix())
+                .isEqualTo(new IpPrefix("fd0b:0c0d:0e0f:1011::/64"));
+        verify(mockRandom, times(2)).nextBytes(any(byte[].class));
+        assertThat(dataset.getPanId()).isEqualTo(0xfffe); // PAN ID <= 0xfffe
+        verify(mockRandom, times(1)).nextInt(eq(0xffff));
+        assertThat(dataset.getChannel()).isEqualTo(26);
+        verify(mockRandom, times(1)).nextInt(eq(16));
+        assertThat(dataset.getChannelPage()).isEqualTo(0);
+        assertThat(dataset.getChannelMask().size()).isEqualTo(1);
+        assertThat(dataset.getPskc())
+                .isEqualTo(
+                        new byte[] {
+                            30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45
+                        });
+        assertThat(dataset.getNetworkKey())
+                .isEqualTo(
+                        new byte[] {
+                            30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45
+                        });
+        verify(mockSecureRandom, times(2)).nextBytes(any(byte[].class));
+        assertThat(dataset.getSecurityPolicy())
+                .isEqualTo(new SecurityPolicy(672, new byte[] {(byte) 0xff, (byte) 0xf8}));
+    }
+
+    @Test
+    public void builder_buildWithTooLongTlvs_throwsIllegalState() {
+        Builder builder = new Builder(ActiveOperationalDataset.createRandomDataset());
+        for (int i = 0; i < 10; i++) {
+            builder.addUnknownTlv(i, new byte[20]);
+        }
+
+        assertThrows(IllegalStateException.class, () -> new Builder().build());
+    }
+
+    @Test
+    public void builder_setUnknownTlvs_success() {
+        ActiveOperationalDataset dataset1 = ActiveOperationalDataset.fromThreadTlvs(VALID_DATASET);
+        SparseArray<byte[]> unknownTlvs = new SparseArray<>(2);
+        unknownTlvs.put(0x33, new byte[] {1, 2, 3});
+        unknownTlvs.put(0x44, new byte[] {1, 2, 3, 4});
+
+        ActiveOperationalDataset dataset2 =
+                new ActiveOperationalDataset.Builder(dataset1).setUnknownTlvs(unknownTlvs).build();
+
+        assertThat(dataset1.getUnknownTlvs().size()).isEqualTo(0);
+        assertThat(dataset2.getUnknownTlvs().size()).isEqualTo(2);
+        assertThat(dataset2.getUnknownTlvs().get(0x33)).isEqualTo(new byte[] {1, 2, 3});
+        assertThat(dataset2.getUnknownTlvs().get(0x44)).isEqualTo(new byte[] {1, 2, 3, 4});
+    }
+
+    @Test
+    public void securityPolicy_fromTooShortTlvValue_throwsIllegalArgument() {
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> SecurityPolicy.fromTlvValue(new byte[] {0x01}));
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> SecurityPolicy.fromTlvValue(new byte[] {0x01, 0x02}));
+    }
+
+    @Test
+    public void securityPolicy_toTlvValue_conversionIsLossLess() {
+        SecurityPolicy policy1 = new SecurityPolicy(200, new byte[] {(byte) 0xFF, (byte) 0xF8});
+
+        SecurityPolicy policy2 = SecurityPolicy.fromTlvValue(policy1.toTlvValue());
+
+        assertThat(policy2).isEqualTo(policy1);
+    }
+}
diff --git a/thread/tests/unit/src/android/net/thread/OperationalDatasetTimestampTest.java b/thread/tests/unit/src/android/net/thread/OperationalDatasetTimestampTest.java
new file mode 100644
index 0000000..32063fc
--- /dev/null
+++ b/thread/tests/unit/src/android/net/thread/OperationalDatasetTimestampTest.java
@@ -0,0 +1,61 @@
+/*
+ * 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.thread;
+
+import static com.google.common.io.BaseEncoding.base16;
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertThrows;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SmallTest;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Unit tests for {@link OperationalDatasetTimestamp}. */
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public final class OperationalDatasetTimestampTest {
+    @Test
+    public void fromTlvValue_invalidTimestamp_throwsIllegalArguments() {
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> OperationalDatasetTimestamp.fromTlvValue(new byte[7]));
+    }
+
+    @Test
+    public void fromTlvValue_goodValue_success() {
+        OperationalDatasetTimestamp timestamp =
+                OperationalDatasetTimestamp.fromTlvValue(base16().decode("FFEEDDCCBBAA9989"));
+
+        assertThat(timestamp.getSeconds()).isEqualTo(0xFFEEDDCCBBAAL);
+        // 0x9989 is 0x4CC4 << 1 + 1
+        assertThat(timestamp.getTicks()).isEqualTo(0x4CC4);
+        assertThat(timestamp.isAuthoritativeSource()).isTrue();
+    }
+
+    @Test
+    public void toTlvValue_conversionIsLossLess() {
+        OperationalDatasetTimestamp timestamp1 = new OperationalDatasetTimestamp(100L, 10, true);
+
+        OperationalDatasetTimestamp timestamp2 =
+                OperationalDatasetTimestamp.fromTlvValue(timestamp1.toTlvValue());
+
+        assertThat(timestamp2).isEqualTo(timestamp1);
+    }
+}